主题:一次由 AI 编码 Agent 编写的纯样式 PR,为什么两位 reviewer 的意见几乎全落在 CSS 层,以及不懂前端的人如何建立自己的 CSS review 能力
分析对象:Codex 在
adastralab-ai/voyagerPR #4864 ↗(fix(site): constrain long brand menus)中的实现,修复 issue #4681 ↗PR 状态(复盘时):
OPEN/ 非 draft /REVIEW_REQUIRED/mergeStateStatus: BEHIND/ CI 全绿 / 仍有 2 条未解决 review 线程结论性质:单个真实任务的行为样本,不是模型的通用 Benchmark
0. 先给结论#
这次 PR 只有约 270 增 / 35 删、11 个文件,纯样式改动,功能目标很小(品牌下拉列表过长时限制高度并内部滚动)。但它经历了 litemo(项目内 AI reviewer)和一位人类 reviewer 的多轮往返,绝大多数意见都集中在样式这一层。最值得记录的不是「Codex 会不会写 CSS」,而是:
Codex 为了满足一个极端场景(约 120px 矮视口也要能收缩滚动),把「滚动 + 拉伸 fallback」做成了共享
PopoverPrimitive的全局默认,于是不断需要给其他 popover 打补丁,并改坏了通用定位行为。
也就是说,这次样式问题的根不是「某句 Tailwind 写错」,而是一个工程判断问题:改动的炸半径没有被控制住 —— 一个单页面需求,被实现成了影响全 app 所有 popover 的共享改动。
对应到方法论,本次最可迁移的一条是:
CSS 的 bug 几乎都是「读 diff 看不出来、渲染出来才看得见」的行为(裁切、堆叠、定位 fallback、层叠优先级)。所以 CSS review 的正确姿势是「先看渲染,再看代码」,而不是逐行读样式。不懂 CSS 不是主要障碍,「没有把它渲染出来看」才是。
| 维度 | 本次观察 | 判断 |
|---|---|---|
| CSS 语法/工具使用 | Codex 能正确使用 Anchor Positioning、position-area、position-try 等较新特性 | 语法层没问题,甚至偏超前 |
| 边界场景意识 | 会主动补 Storybook stories 覆盖多品牌/矮视口 | 有意识,但是被 reviewer 反复推动后才补全 |
| 炸半径控制 | 把单页面需求实现成共享 primitive 的全局默认(opt-out) | 本次最大问题 |
| 过度设计 | 出现冗余夹取、同值两处两种单位、少见的 most-height 等 | 倾向「想太多」而非「刚好够」 |
| 事后修正 | 每条 review 都认真回应并给出 commit | 修正态度好,但常是补丁式而非收敛式 |
综合判断:Codex 在这次任务里不是不会写 CSS,而是不擅长控制样式改动的影响面,并倾向用「改全局默认 + 逐个打补丁」的方式解决局部问题。这个分数只对本次任务负责,不外推为模型普遍能力。
1. 分析方法与证据边界#
本报告交叉读取了四类证据:
- PR #4864 相对
origin/main的完整 diff; - issue #4681 的原始 bug 描述与期望表现;
- PR 上 litemo 与人类 reviewer 的全部 review 线程(含已解决与未解决);
- 复盘时 PR 的账本状态(
state/reviewDecision/mergeStateStatus/ CI)。
证据边界与阅读约定:
- 代码验证事实:diff 里实际存在的类名、属性、文件行,可直接复查。
- 基于证据的推断:对 CSS 运行时行为(尤其
position-try-order: most-height与align-self: stretch的组合效果)的解释属于推断,需在真实浏览器里验证,见第 6 节「待确认」。 - 不外推:这是 Codex 在一次纯样式任务里的行为样本,不代表它在其他任务或其他前端子领域的平均水平。
- 代码引用使用仓库相对路径
文件:行,不含本地文件系统信息。
2. 任务背景#
issue #4681(type:bug / scope:site / size:small):品牌较多时,Brand DNA 页面的品牌下拉列表会一直向下延伸并超出视口,底部品牌滚不到、选不了,且列表内部没有滚动。期望:列表最多显示约 8.5 项(露半项提示可继续滚动),超出后容器内部滚动,下拉层不超出视口、不依赖整页滚动。
PR #4864 的方案:把共享的 PopoverPrimitive 从手写 anchor() 定位改为 CSS Anchor Positioning 的 position-area + position-try-fallbacks;优先保持指定方向,空间不足自动 flip,两侧都放不下时限制在可用区并启用内部滚动。品牌选择器额外设置约 8.5 项的高度上限。不支持 Anchor API 的浏览器走居中 fallback。
方案方向本身是对的(reviewer 也认可 position-area 的用法)。问题出在落地时把行为放在了哪一层。
3. 实现问题分类(代码证据)#
3.1 根因:把 overflow 塞进共享 PopoverPrimitive(opt-out 打地鼠)#
packages/ui/src/components/PopoverPrimitive/PopoverPrimitive.tsx:249 给每一个 popover 都加了:
fixed inset-[unset] max-w-[calc(100dvw-1rem)] overflow-x-hidden overflow-y-auto overscroll-containplaintextCSS 里两个轴只要一个是非 visible,另一个也会被强制成裁切。于是任何子元素画到盒子外的东西(阴影、focus ring、装饰性 pill)都会被裁掉。直接证据是 PR 里已经被迫给三个 consumer 手动 opt-out:
packages/site/src/app/(main)/files/[id]/_components/DrawFloatingToolbar.tsx:67→ 加overflow-visiblepackages/site/src/app/(main)/files/[id]/_components/EditorSidebar.tsx:324→ 加overflow-visiblepackages/site/src/app/(main)/files/[id]/_components/SheetToolbar/SheetInsertMenu.tsx:179→ 传popoverClassName="overflow-visible"
即便如此,litemo 仍在后续轮次继续发现被裁的位置(先是 Pen/Line 子面板,再是外层 Draw 容器)。这是典型的 opt-out 打地鼠:滚动本应是少数需要的 consumer 自己 opt-in,而不是强加给全 app 再逐个豁免。
3.2 position-try-order: most-height + 全 scrollable fallback:改坏通用定位(未解决线程)#
PopoverPrimitive.tsx:250:
[position-try-fallbacks:--anchored-panel-scrollable,--anchored-panel-scrollable_flip-block,--anchored-panel-scrollable_flip-inline,--anchored-panel-scrollable_flip-block_flip-inline] [position-try-order:most-height]plaintext其中 --anchored-panel-scrollable(定义在 packages/ui/components.css:231)会 align-self: stretch; min-height: 0。相比改动前,旧的 fallback 是纯 flip(flip-block, flip-inline, flip-block_flip-inline)。新列表里已经没有任何纯 flip 候选,所有 fallback 都会拉伸铺满并启用滚动。
推断的后果(需浏览器验证):一个只是需要 flip 的普通 popover(如 bottom 放不下翻到 top),不再是「翻过去、内容高度」,而是被拉伸 + 出现滚动条;most-height 还可能在任何另一侧空间更大时覆盖作者显式指定的 placement。这正是人类 reviewer 在 PopoverPrimitive.tsx:250 留下的未解决意见:「为什么删掉 flip-block/flip-inline/...?这似乎破坏了 app 里多个 popover。」
值得记录的过程:Codex 走到这一步,是被 litemo 的「120px 视口下最后一项仍在屏外」反馈逼出来的(litemo 指出「append 在已有候选之后 Chromium 会忽略」)。于是 Codex 用牺牲常规 case 的方式硬修了极端 case。这是「过度拟合 corner case」的具体样本。
3.3 冗余 / 无意义样式(过度设计)#
packages/site/src/app/(main)/brands/_components/BrandSelector.tsx:32:w-[min(18rem,calc(100dvw-1rem))]。里面的calc(100dvw-1rem)夹取是多余的 —— primitive 已全局加了max-w-[calc(100dvw-1rem)],直接w-72即可。同时把原来的min-w-72(随内容增宽)悄悄改成固定宽度,属于语义漂移。packages/ui/src/components/PopoverPrimitive/anchorStyles.ts:37:非 Anchor fallback 内联maxWidth: "calc(100dvw - 16px)",与 primitive 的max-w-[calc(100dvw-1rem)]同值写两遍、两种单位(px vs rem)。maxHeight 版本正是 litemo 的另一条未解决意见(Safari ≤18 上内联 cap 会盖过 consumer 的max-h-*)。
3.4 一致性小问题#
- 同一个「视口留白」量,primitive 用
1rem,fallback 内联用16px,数值相同但不统一。 overscroll-contain与 overflow 现在也套在永远不会滚动的 tooltip、小 popover 上,无害但属于一刀切。
4. Review 动态:为什么意见几乎全在样式层#
两位 reviewer:litemo(项目内 AI reviewer)+ 一位人类 reviewer。多轮往返,大部分已解决(阴影裁切、cn→cx、移除 --popover-max-height 自定义属性、简化 sizing、100px floor 导致极小视口失效等),但复盘时仍有 2 条未解决线程,都在样式层:
PopoverPrimitive.tsx:250(人类 reviewer):删掉纯 flip 候选破坏了多个 popover(见 3.2)。anchorStyles.ts:38(litemo):Safari ≤18 fallback 的内联 cap 盖过 consumer cap(见 3.3)。
两条观察值得记录:
- AI reviewer 在 CSS 边界情况上很强:litemo 抓的「阴影被裁」「120px 视口」「Safari fallback 覆盖」都又准又具体。人类 reviewer 也明确建议「反复跑 litemo 直到它没有更多意见」。
- 人类 reviewer 提供了 AI 不容易给的东西:直接给出更简单的对照实现分支、指出「炸半径」层面的回归、以及要求补 Storybook stories 以便复现边界。
结论:CSS review 里,AI 负责穷举边界,人类负责判断影响面与取舍,两者互补。
5. 模型行为观察与边界#
- Codex 的 CSS 语法能力不弱,甚至偏超前(主动用 Anchor Positioning /
position-area/position-try)。问题不在「会不会写」。 - Codex 的工程判断偏弱:倾向把局部需求实现为全局默认(opt-out),并用逐个打补丁收尾;面对极端场景倾向「过度拟合」,牺牲常规 case。
- Codex 的事后修正态度好但常是补丁式:每条 review 都认真回应,但解法常是再加一个覆盖/豁免,而不是回到根因收敛。
- 以上均为单次纯样式任务的样本。不能据此断言「Codex CSS 差」;更准确的描述是「本次任务中,Codex 的样式改动影响面控制不足」。
6. 方法论:不懂前端的人如何 review AI 写的 CSS#
这一节把本次经验固化成可复用的做法,面向「不熟前端、不爱调试、但想积累自己 CSS review 判断」的人。
6.1 先看渲染,再看代码#
CSS 的问题读 diff 看不出来,只有渲染出来才看得见。所以顺序要反过来:先看渲染结果发现哪里不对,再回头定位是哪段样式。你不爱读代码,在 CSS review 上不是短板 —— 只要你会「看」。
6.2 Storybook vs 本地 dev server:用哪个看 UI#
- 本地 dev server(真 app):看「整个真实 app 在我账号当前这一种状态下」的样子。要看极端场景(15 个品牌 + 矮屏幕),得真去登录、真建 15 个品牌、拉矮窗口,很多状态甚至造不出来。
- Storybook:一个独立小网站,把单个组件在预先指定的 N 种状态下逐个渲染出来;状态是假数据直接塞进去的(本 PR 的
BrandSelector.stories.tsx用withBrands(manyBrands)直接喂 15 个品牌),不需要后端、登录、真实数据。左边列表点一下就切换场景。
| 本地 dev server(真 app) | Storybook | |
|---|---|---|
| 造极端场景 | 得真去凑,甚至凑不出 | 假数据直接塞,点一下就有 |
| 依赖 | 后端 / 登录 / 数据 / 网络 | 组件单独跑,无依赖 |
| 一次看几种状态 | 只有当前那一种 | 左边一排随便切 |
| 场景是否漂移 | 数据一变就没了 | 冻结成 story,永远在 |
| 自动截图对比 | 不能 | 能(接 Chromatic 自动圈变化) |
| 真实集成环境 | ✅ 最真实 | ❌ 孤立看组件 |
用法是互补:先用 Storybook 把组件各状态扫一遍抓样式问题(便宜、全面),关键流程再回真 app 验一次集成(真实)。对本 PR,90% 的坑用 Storybook 就能看出来,因为 bug 全在「多品牌 / 矮屏」这种 Storybook 一点就到的边界态。
备注:Storybook 是否「重 / 要额外配置」取决于仓库有没有搭好。voyager 已经有独立的
packages/storybook包,根目录pnpm storybook:dev一条命令即起(端口 6006),使用者零配置。
6.3 一个不需要懂 CSS 的判断:炸半径 + opt-in#
看 diff 时只需问一句:「这个改动动到共享组件(如 packages/ui/)了吗?影响的是全 app,还是只有我这个页面?」 判断炸半径只看文件路径,不需要 CSS 知识。
- 动了共享 primitive → 高度警惕,要求先列出所有 consumer 并逐个确认不 regress。
- 只动了单页面组件 → 炸半径小,放心。
配套概念 opt-in / opt-out:
- opt-out(默认开,不要的人自己关):本 PR 现状 —— 所有 popover 默认滚动+裁切,不想要的自己写
overflow-visible关掉 → 打地鼠。 - opt-in(默认关,要的人自己开):更好 —— popover 默认不变,只有品牌菜单主动开启滚动。
一句话:改共享组件时,opt-in 几乎永远比 opt-out 安全,因为它不波及无辜。
6.4 为什么不要「写死 z-index」#
z-index 确实是控制堆叠顺序的,没人说不能控制。问题不在于用 z-index,而在于用全局写死的大数字:
- z-index 只在同一个 stacking context 内比较,不是全局排序。这解释了「明明写了 9999 还被盖住」—— 它被关在一个上下文里出不去。
- 全局魔数会引发军备竞赛:各组件各写各的(100 → 999 → 9999 → …),没有统一坐标系,最后没人知道谁该在上面。
所以约定不是「禁止控制顺序」,而是「用可控方式」:
- 优先靠 DOM 顺序:后出现的元素天然盖在上层,调整标签顺序即可,不用 z-index。
- 非用不可就
isolation: isolate:给父元素加它,新建一个局部 stacking context,z-index 只在圈内生效、不泄漏出去打架。 - 弹层用 portal:voyager 的 popover/modal 渲染到
<body>末尾,天然在最上层,压根不需要 z-index。
一句话:z-index 本身没问题,「全局写死的大数字」才是;优先 DOM 顺序,非用不可就 isolation: isolate 圈在局部。
6.5 气味卡(靠模式匹配,不靠原理)#
看到下面这些不一定是错,但值得追问一句「这是必须的吗」:
| 看到 | 追问 |
|---|---|
改了 packages/ui/ 共享组件 | 影响全 app 吗?consumer 都验过吗? |
多个文件都在加 overflow-visible / override 补丁 | 是不是某个共享改动太宽,在打地鼠? |
overflow 加在共享容器上 | 会不会裁掉子元素阴影/描边? |
z-index 写死数字 | 本仓库约定尽量不用 z-index |
出现 [...] 任意值 / calc() / min() / most-height 等少见写法 | 能不能用普通 utility?是不是想太多? |
| 同一个值出现两遍 / 两种单位 | 冗余,留一个 |
用 inline style={{...}} 放静态值 | 违反约定,且优先级会盖过 class |
6.6 个人「坑本」循环#
CSS review 经验主要不是靠读代码,而是靠「见过的坑」。可复用的循环:
- 每次 review 先看渲染,再看 diff;
- 建一个「CSS 坑本」,每个真实 bug 记三行:现象 / 根因 / 以后看到什么就起疑;
- 坑本长起来后就是你的个人 checklist,靠模式匹配即可,不需要懂原理。
本 PR 直接贡献两条坑本词条:
- shared 组件加
overflow→ 会裁子元素阴影/描边(并强制两个轴都裁)。 - 为极端情况改全局默认(opt-out)→ 炸其他 consumer,且会走向逐个打补丁。
7. 待确认问题#
position-try-order: most-height与--anchored-panel-scrollable(align-self: stretch)组合后,普通 popover 是否会在「本可原地显示」时也被拉伸/翻转?第 3.2、3.3 的后果描述属推断,需在 Chromium/Safari 真实渲染下确认。- 是否存在既能满足 120px 极端视口收缩、又保留纯 flip 通用行为的 fallback 顺序(例如把纯 flip 放前、scrollable-stretch 作为最后候选),且不触发 litemo 观察到的「Chromium 忽略靠后候选」。这决定了「opt-in 收窄影响面」重构是否可行。
- Safari ≤18 fallback 分支改为「移除内联 cap、在 primitive 上加
max-h-[calc(100dvh-1rem)]让 consumer 更窄的max-h-*生效」后,是否在真实 Safari 上保住约 8.5 行上限。 - 复盘时 PR
mergeStateStatus: BEHIND;合入origin/main后是否引入新的定位/裁切交互,需重跑 Storybook 边界态与关键页面。