internal/ was 141 packages side by side, so nothing but a reader's memory
said which were foundations and which assembled the rest. Each now sits in a
layer directory, and the directory is the declaration:
base L0 foundation with no domain in it
contract L1 types and interfaces the layers above agree on
model, tools, safety, ext, state, platform
L2 subsystems, peers of one another
runtime L3 the turn engine
session L4 the controller
assembly L5 the composition root
frontend L6 cli, serve, acp and the host adapters
Only paths change: package names, code and behaviour do not. Imports,
repolint and coverage baselines, sensitive paths, CI package lists and docs
follow the new paths; release notes keep the paths they were written with,
and sample paths inside tests and fixtures keep theirs.
repolint gains layer-order: a package may import its own layer and those
below it, read from where it lives. The 47 imports that point upward today
are recorded as that rule's opening debt (-allow-widen, a new rule's first
baseline) and can only go down. Most of it is config, which still reaches
into eight subsystems and is the next thing to take apart; the rest is
event, history and a few tools reaching into runtime for types that belong
in contract.
14 KiB
性能台
在真实浏览器里量 Studio 的流式渲染成本,以及在真实浏览器里才能问的那些判据。
界面的判据分三层,这里是第三层:
- 静态 ——
src/**/*.test.tsx走renderToStaticMarkup:给定一份状态,画出 来的是什么。不需要 DOM,也最快。 - 交互 ——
src/**/*.interaction.test.tsx走 jsdom + Testing Library:点、 按键、焦点、在飞、被拒、连点。文件头一行// @vitest-environment jsdom, 其余测试仍然跑在 node 环境里。控件的契约默认放这一层。 - 浏览器 —— 这里。真实布局、几何、命中、滚动归属、CSS 过渡、外壳 —— jsdom 一样都答不了。只有这些问题该上这一层:每个小控件都起一次浏览器,UI-1 就推不动了。
harness.tsx 挂的是完整的 App,事件流由驱动脚本注入 —— 不需要内核,也不会
把固件带进生产包(perf.html 不是主构建的入口)。
必须按生产构建量:React 的开发期检查(jsxDEV、validateProperty)比
被测代码本身还贵,dev 下的数字会把结论带偏。
带 --sourcemap 构建:两个采样脚本会把热点还原成源码位置(sourcemap.mjs)。
不带的话表里只有 h、Gv 这样的压缩名,读不出是谁。
PERF_CPU=4(或 6)给 bench / profile / switch / switchprof / rail /
panes / scale 降频,量的是低端机。开发机上什么都是 60fps —— 报上来的卡顿
从来不在这一档,而且降频前后热点排序并不一样:真正贵的是在提交阶段读布局,
它在快机器上便宜到看不见。
npx vite build --config vite.perf.config.ts --sourcemap # 产出 dist-perf/
npx http-server dist-perf -p 4399 -s # 或任意静态服务器
pnpm exec playwright install chromium # 首次:npx 会另装一份版本对不上的
node perf/bench.mjs # 规模基准:0/40/150/400 轮下的帧时间与脚本/样式/布局
node perf/scale.mjs # 极长会话:400/2000/8000/20000 轮的节点数、堆内存、帧时间
node perf/switch.mjs # 切会话:点开一份已经很长的记录,到它出现在眼前要多久
node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500 的节点数与响应
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
node perf/i18n.mjs # 词表守卫:源码用到的中文 key 与英文词表是否对得上
node perf/selfcheck.mjs # 守卫的守卫:往每个断言脚本塞一条必假断言,看它是否真的会红
node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端都要有话说
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
lang.mjs 从仓库外跑时用 PERF_SRC 指向 src/(它要读固件源码来决定
哪些中文不必翻)。PERF_URL 可以指向别的地址;bench.mjs 和 scale.mjs 另收 PERF_SCALES、
PERF_DELTAS,rail.mjs 收 PERF_TREE(区数x每区会话数,逗号分隔),
switch.mjs 收 PERF_TURNS 和 PERF_STATUS_MS——后者把内核 /status 的
往返摆进来,用来分清「等的是这份记录」还是「等的是一次状态读」。
性能台自己也读 ?ws=&sess= 来决定左栏那棵树有多大。
判据
这几条是这套 UI 的性能契约,回归时先看它们:
- 脚本时间与会话长度无关。 20000 轮和空会话应当在同一量级。一旦随轮次
线性上涨,说明又有派生值挂回了
items而不是revision,或者哪个组件 的 memo 被一个每帧新建的 prop 打掉了。 - DOM 节点数与会话长度无关。
scale.mjs里 20000 轮应当只有几千个节点。 涨起来就是有列表在无上限地渲染——转录的块虚拟化失效,或者又多了一个像 「待审改动」那样一行一条目的面板(那个面板曾经一家就占 28k 节点)。 - 左栏节点数与会话总数无关。
rail.mjs里 40 区 × 500 会话应当在几千个 节点、折叠一下在 120ms 内。这三处是同一个毛病的三个地方:任何"一条数据一 个 DOM 行"的列表都要有上限或虚拟化。 - 长任务为 0。 出现长任务意味着某一帧做了整条记录的活。
- 切一次会话,等的只能是那份记录。
switch.mjs在PERF_STATUS_MS=0与=100两次之间的差值,就是有多少等待其实是在等一次状态读——那笔账会一比一 落到「到内容」上。转录的恢复不该和状态读绑在同一个Promise.all里:记录已 经到手就该画出来,状态晚一点到只是状态栏晚一点更新。 - 折条目的耗时不该随会话长度加速上涨。
fromHistory里每来一条工具结果就 线性找一次配对、每折一个条目就复制一次已折好的数组,两处都是 O(n²);轮次翻 倍而耗时涨到近四倍,就是它们又回来了。 - 看不见的东西不许动,闲着的窗口不许烧 CPU。 一条动画不会因为看不见就停
下:
.glowring的注释写着「停下就必须熄灭」,熄灭的却只有 opacity,里面那个 150px 的径向渐变照旧沿边框跑,每秒 60 次在一层 mask 里重新光栅化——闲置窗口 三成主线程,报上来的是「webview2 占 50%,关掉软件立刻回到 1%」(#9481)。idle.mjs两条断言:在跑的动画,它的元素必须真的看得见(opacity 为 0、 visibility 不可见、盒子没面积,三者任一就是错,机器再快也是错的);以及动画 在闲置窗口上的净开销——同一台机器把动画全关再量一遍,两者相减——要在预算 以内,机器快慢在相减里抵消,所以这个数在开发机和 CI 上是同一个。可见性那条是 结构判据,不会因为机器快就变绿,它才是真正的闸门。 - 采样里不该有
get scrollHeight。 它是强制同步布局,跟随滚动一旦回到 读几何属性的写法,就会每帧把整条记录重新排版一遍。读scrollTop不要紧, 强制布局的是scrollHeight/clientHeight/offsetHeight。 - 块的装卸必须收敛。
verify.mjs会检查远处的块已卸载。历史教训:给每个 块各建一个 IntersectionObserver 时,几百个实例的回调并不可靠,块会滞留在 视口外 40 万像素处不卸载——必须是一个观察器观察多个目标。 - 栏收起时看得见地收。
panels.mjs量的是那 45 帧里落在两头之间的宽度有 几个,因为这件事退化时不报错。补间挂在--rail-w/--side-w上——两个用@property注册成长度的变量;grid-template-columns自己插不了值,那一列 里有minmax(0, 1fr),而它只在纯长度之间可动画,写上 transition 也只会安静 地退回跳变。拖动期间必须断开补间(.app[data-drag]),否则栏是追着指针走的。 - 一次只有一行是"当前"。
pick.mjs量的是补全菜单里自己画了底的行有几个。 悬停和键盘选中曾经是两套记号,重的那套还给了悬停——指针停在列表上不动,画面 上最亮的就是回车不会拿走的那一行。填色要压过悬停,还要往同一个方向压:--accent-wash是照--page调的,落在--float上深色下比面板还暗。 - 列表长到要搜,就得能搜、能填、有上限。
models.mjs量的是连接面板在网关 规模下的样子:一个中转站报出一百多个只差日期后缀的名字,编辑面板曾经一行一个 全铺出来,整块 3914px、687 个节点,保存按钮在四屏之外。上限只砍没勾的尾巴, 砍掉多少要写出来;搜和「填一个列表里没有的」是同一个框——两个框就是两个控件 做一件事,用户还得先猜自己的模型在哪个后面。 - 别人写的文本不决定我们的栏宽。
side.mjs量的是右栏的横向溢出。MCP 连不上 时error是端点自己写的一整句话,长度不由我们定;它曾经被塞进.job .rt—— 那是给时长准备的槽,flex: none不收缩,于是服务名(min-width: 0)先被挤到 0px,整栏横向溢出 451px,旁边每一块跟着错位。机器给的文本只能换行、只能截, 而且要overflow-wrap: anywhere:报错里常是一整条没有空格的 PATH 或 URL。 - 身份不是先被裁掉的那个。 文件行早就写着这条(先没的恰好是文件名),外部 服务行没有:名字和原因挤在一行时,输的永远是名字,而它正是"哪个服务坏了"的 答案。两行解决——名字一行、原因一行,原因夹到两行以内。
- 对比度是算出来的,不是看出来的。 上一次清扫是人一处一处看的(浅色下五处
不达 AA),于是它只在有人去看的那一天成立;而配色是令牌,一个值动了,受影响
的是所有引用它的地方。
contrast.mjs把每一段正文和它真正落在的那块底比: 底要逐层合成(半透明的底不是底),颜色要按 CSS Color 4 解析(令牌是 oklch、 color-mix 算出 oklab,把分量当 RGB 读,色相角 255 会变成蓝通道),大字按 AA 原文豁免(24px 起,或 18.66px 且 700)。名单是空的,所以任何一处新的不达标 都是红的。四档文字色里 ghost 的余量是照--page给的 —— 它落在抬面 (--raised/--overlay/--float)上就不够,那里最弱的一档是 faint。 - 没选过语言就跟机器走。 判据在
i18n/index.ts的isChinese:以 zh / yue / cmn 开头,或含 hans / hant,都是中文;其余一律英文——英文是兜底,不是 对机器语言的判断。只看首选语言:一台法语机器把中文排第二,答案仍是英文。 - 内核记着的语言压过本地缓存。 本地那份只是启动时不闪的缓存;换机器、
清缓存时靠
adopt()采纳 config 里的选择,并且只重载一次就收敛。 - 内核不说人话。 拒绝走
refuse/busy/busyErr/refusal,交出去的是 码加参数;error字段只是英文兜底,给日志和 curl 看,不是给用户看的译文。 新增一条拒绝就要在i18n/kernel.ts里给它一句话 ——perf/codes.mjs两侧 都读源码来对,不是对一份手抄清单。 - 词表不能悄悄缺。 缺翻译时界面退回中文而不是空白,所以漏了不会自己暴露,
perf/i18n.mjs是替代品:源码里每个t("…")的中文 key 都要在i18n/en.ts里有。这条对应 internal/base/i18n 的 catalog 测试,理由一样。 它读的是源码文本,所以它自己也得说得出哪一行没读懂。中文是合法的 JS 标识符,词表里"占用": "…"和占用: "…"两种写法都有,而它一度只认带引号 的那种——存储面板那六条于是被报成缺翻译,那六条其实一直都在。一条永远红的守卫 和一条永远绿的一样没用:它不再是在说源码的事,只是在说自己。现在读不出键的行 会被单独列出来并让退出码变红,第三种写法不会再安静地混过去。 - 跟随只由用户手势解除。 用「底部标记离开视口」来解除是错的:块挂载会把 底部推走,那样视口会被永久留在半空。
- 预览是窗口的形状,不是随手给的一条。
cover只在图溢出的那条轴上给焦点 留余量,所以长宽比一换,能动的就是另一条轴。壁纸预览曾经是固定 116px 高的 一条(约 5:1),窗口是 16:10——横向焦点在预览里一个像素都不动,报上来就是 「焦点滑动失效」(#9432)。look.mjs量三件事:预览与窗口的长宽比、有余量 那一档拉满时预览真的变了几个色阶、没余量那一档是不是停用并说得出为什么。 拉了不动的控件比没有这个控件更糟,所以余量是量出来的(图的自然尺寸对预览 的实际尺寸),不是按平台或图片类型猜的。