DSH / Atlas
2026-09-01implementedfeaturearchived 2026-09-04

Web elevation — hairline stroke drawn in shadow

Web elevation — hairline stroke drawn in shadow

Elevated web-client surfaces — menus, popovers, modals, panels, floating buttons, the composer — each paired a real `border: 1px solid <neutral token>` with a `--dsw-shadow-lv2`/`lv3` shadow. The border consumes layout (1px per side, and it is the UA-default replacement on `<button>` elements), the light theme drew most floats with no stroke at all (`--dsw-alias-border-inverted` is transparent in light) while `lv3` f

English

Problem

Elevated web-client surfaces — menus, popovers, modals, panels, floating buttons, the composer — each paired a real border: 1px solid <neutral token> with a --dsw-shadow-lv2/lv3 shadow. The border consumes layout (1px per side, and it is the UA-default replacement on <button> elements), the light theme drew most floats with no stroke at all (--dsw-alias-border-inverted is transparent in light) while lv3 faked one with a blurred 1px ring, and the composer wore a broad soft lv2 patch that read as a smudge rather than a lifted surface. Current desktop chat UIs instead draw elevation as one box-shadow list: a 0.5px hairline stroke plus two faint soft layers, with border: 0 on the surface.

Decision

gradient-shadow-text.css (the ui-theme shadow owner) defines the elevation tokens beside the --dsw-shadow-lv* scale:

  • --dsw-elevation-stroke-color — the hairline color, defaulting to --dsw-alias-border-l4 (black 16% light, white 20% dark); components rebind it per surface or state: every menu-fill surface (--dsw-specific-menu background) rebinds the lightest --dsw-alias-border-l1 and the composer rebinds --dsw-alias-border-l2, both quieter than panels and buttons. The default is declared on body alone while the derived tokens below are re-declared on body, body *: a custom property computes with var() already substituted, so body-only derived tokens would bake in body's color and make every rebind a no-op (the same per-element re-substitution scrollbar.css states for --dsh-scrollbar-thumb).
  • --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color) — the stroke alone, used standalone by inline cards that want only an outline (the plugin-inventory card).
  • --dsw-elevation-panel / --dsw-elevation-prominent — the stroke plus two faint soft layers (3px directional + 16/20px glow at 2–5% black), panel for small floating widgets and cards, prominent for floats, and soft — larger blur at lower alpha — for the composer.

Converted surfaces set border: 0 and one elevation shadow: every --dsw-shadow-lv3 float (Menu, Modal, popup selects, model select, usage/context popovers, feedback actions, schedule/job popovers, subagent lineage, settings panel, cordis panel, experimental team panel) takes prominent, and the lv2 surfaces (scroll-to-bottom button, turn-preview card, attachment-rail arrows, question composer, trajectory tooltip) take panel. The composer card takes the soft tier with the l2 stroke rebind, and its workspace-trigger state sets the stroke color transparent instead of the former border-color: transparent. Dark theme needs no shadow overrides: the soft layers are near-invisible there and the stroke carries the separation.

packages/client/ui-theme/tests/elevation-styles.client.spec.ts pins the token composition and scans every stylesheet under packages/: a rule pairing an lv/elevation box-shadow with a --dsw-alias-border-* border fails, and every solid border on a neutral --dsw-alias-border-* token must be 0.5px wide. Deliberate keeps: Toast and HoverCard (inverted fills where a theme-following stroke is meaningless), ImageLightbox (bare image), and the warn-bordered approval/plan panels, whose state-colored borders stay real borders and pass the scan.

Flat widgets keep real borders at hairline width: every neutral-token 1px solid border — buttons (the shared outline variant, add/retry/inspect buttons), inputs, inline cards, code blocks, and the settings row separators — is 0.5px solid, with full-box strokes deepened to --dsw-alias-border-l4 (buttons one step lighter at --dsw-alias-border-l3) while row separators keep --dsw-alias-border-l2; state logic (border-color swaps on focus/hover) is unchanged. Separators drawn as filled boxes take the same weight: the 1px-tall or 1px-wide lines with a border-token background (menu separators, the conversation header seam, markdown hr, the tool IO dividers, the trajectory rail, the directory-browser divider) are 0.5px, and the context-injection divider that read the never-defined --dsw-alias-line-secondary (so it never rendered) now draws 0.5px solid var(--dsw-alias-border-l2). Chromium paints sub-device-pixel borders as one device pixel, so 1x displays render exactly the former line and 2x displays get the hairline. Dashed affordances stay 1px (a 0.5px dash pattern degrades), and the two border-drawn spinner rings keep their track width through the spec's explicit allowlist.

Alternatives considered

Keeping 1px real borders and only softening the shadows. Leaves the layout-consuming border, the light-theme stroke gap on border-inverted floats, and the double outline wherever both existed; the stroke-in-shadow form is what produces the crisp hairline edge.

A 1px stroke instead of 0.5px. At 2x displays 0.5px renders one physical pixel, and on 1x it blends lighter, which is the intended hairline. 1px reads as the old border.

Drawing flat-widget hairlines as box-shadow strokes too. Buttons and inputs swap border-color on hover and focus and several pair a box-shadow focus ring; moving their stroke into box-shadow would collide with those rings (one property) and rewrite every state rule, while 0.5px solid keeps the whole state logic and changes only the weight.

Suppressing the composer trigger stroke with box-shadow: none. Also drops the soft layers the trigger state keeps today; rebinding --dsw-elevation-stroke-color: transparent removes exactly the stroke.

Converting Toast/HoverCard too. Their fills are inverted relative to the theme, so the theme-following stroke color is invisible-or-wrong on them; they keep lv3 until an inverted-surface stroke token exists.

Consequences

  • Every converted surface gains a hairline outline in the light theme (most floats previously had none) and loses 1px of border from its box; the visual size change is at most 2px on small buttons and imperceptible on panels.
  • The neutral-border-plus-shadow pairing is now rejected by the ui-theme elevation spec, so a new elevated surface must choose the elevation tokens; the rule lives in docs/web-styling.md.
  • The composer's broad lv2 patch becomes stroke + tight glow; its dark stroke keeps the figma one-notch-weaker value through the rebind.
  • --dsw-shadow-lv1/lv1-blur currently have no consumer and lv2/lv3 remain only on the deliberate keeps; the scale stays for inverted and bespoke surfaces.

中文

Problem

Web 客户端的高层级表面——菜单、浮层、对话框、面板、悬浮按钮、输入框——原先都把真 border: 1px solid <中性 token> 与 --dsw-shadow-lv2/lv3 投影配对。border 占布局(每侧 1px,且在 <button> 上是对 UA 默认边框的替换);浅色主题下多数浮层实际没有描边(--dsw-alias-border-inverted 在浅色下是透明的),靠 lv3 里模糊的 1px 环冒充;输入框则披着一大片柔和的 lv2 投影,读起来更像污渍而非悬浮表面。当前桌面聊天 UI 改用单个 box-shadow 列表绘制 elevation:0.5px 发丝描边加两层极淡柔光,表面本身 border: 0。

Decision

gradient-shadow-text.css(ui-theme 的阴影归属地)在 --dsw-shadow-lv* 阶旁定义 elevation token:

  • --dsw-elevation-stroke-color——发丝描边颜色,默认 --dsw-alias-border-l4(浅色黑 16%、深色白 20%);组件可按表面或状态重绑:所有菜单面(--dsw-specific-menu 背景)重绑最浅的 --dsw-alias-border-l1、输入框重绑 --dsw-alias-border-l2,两者都比面板与按钮安静。默认色只声明在 body 上,而下述派生 token 在 body, body * 上逐元素重声明:自定义属性的计算值已替换完 var(),派生 token 若只在 body 声明会把 body 的颜色固化进去,让所有重绑失效(与 scrollbar.css 对 --dsh-scrollbar-thumb 声明的逐元素重替换是同一契约)。
  • --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color)——单独的描边,供只要轮廓的行内卡片独立使用(插件清单卡片)。
  • --dsw-elevation-panel / --dsw-elevation-prominent——描边加两层极淡柔光(3px 方向光 + 16/20px 辉光,黑 2–5%),panel 用于小型悬浮部件与卡片,prominent 用于浮层,soft——更大模糊、更低透明度——用于输入框。

被转换的表面设 border: 0 加一个 elevation 投影:所有 --dsw-shadow-lv3 浮层(Menu、Modal、弹出选择、模型选择、用量/上下文浮层、反馈操作条、日程/任务浮层、子代理谱系、设置面板、cordis 面板、实验性 team 面板)取 prominent;lv2 表面(回到底部按钮、回合预览卡、附件栏箭头、问题 composer、轨迹 tooltip)取 panel。输入框卡片取 soft 档并重绑 l2 描边,其 workspace-trigger 态把描边色设为 transparent,替代原先的 border-color: transparent。深色主题无需投影覆盖:柔光在深色下几乎不可见,分离由描边承担。

packages/client/ui-theme/tests/elevation-styles.client.spec.ts 钉住 token 组成并扫描 packages/ 下全部样式表:lv/elevation box-shadow 与 --dsw-alias-border-* border 配对的规则即失败;中性 --dsw-alias-border-* token 上的每个 solid border 必须为 0.5px 宽。有意保留:Toast 与 HoverCard(反色填充,跟随主题的描边色在其上无意义)、ImageLightbox(裸图片)、warn 描边的审批/计划面板——状态色 border 保持真 border,扫描放行。

平面部件保留真 border 但取发丝线宽度:所有中性 token 的 1px solid border——按钮(共享 outline 变体、添加/重试/inspect 按钮)、输入框、行内卡片、代码块与设置行分割线——改为 0.5px solid,整框描边加深为 --dsw-alias-border-l4(按钮浅一档取 --dsw-alias-border-l3),行分割线保持 --dsw-alias-border-l2;状态逻辑(focus/hover 换 border-color)不变。以填充盒绘制的分隔线取同一粗细:高或宽为 1px、背景用 border token 的线(菜单分隔、对话标题栏接缝、markdown hr、工具 IO 分隔、轨迹竖轨、目录浏览器分隔)改为 0.5px;上下文注入分隔线原先读取从未定义的 --dsw-alias-line-secondary(因此从未渲染),现在绘制 0.5px solid var(--dsw-alias-border-l2)。Chromium 把亚设备像素 border 绘制为一个设备像素,因此 1x 屏与原先渲染完全一致,2x 屏得到发丝线。dashed 记号保持 1px(0.5px 虚线图案会退化),两个用 border 画的 spinner 圆环经 spec 显式豁免保留轨道宽度。

Alternatives considered

保留 1px 真 border、只调柔投影。 留下占布局的 border、浅色主题 border-inverted 浮层的描边缺口,以及两者并存处的双轮廓;描边入投影正是产生锐利发丝边缘的形式。

用 1px 而非 0.5px 描边。 0.5px 在 2x 屏渲染为一物理像素,1x 屏混合得更浅,正是发丝线意图。1px 读起来就是原来的 border。

平面部件的发丝线也用 box-shadow 描边画。 按钮与输入框在 hover/focus 时切换 border-color,多处还配 box-shadow 焦点环;把描边挪进 box-shadow(单一属性)会与焦点环冲突并重写全部状态规则,而 0.5px solid 保留整套状态逻辑,只改粗细。

composer trigger 态用 box-shadow: none 抑制描边。 会连带丢掉该状态今天保留的柔光;重绑 --dsw-elevation-stroke-color: transparent 恰好只去掉描边。

Toast/HoverCard 一并转换。 其填充相对主题反色,跟随主题的描边色在其上不可见或错误;在出现反色表面描边 token 之前保留 lv3。

Consequences

  • 每个被转换表面在浅色主题下获得发丝轮廓(多数浮层此前没有),盒子少了 1px border;视觉尺寸变化在小按钮上至多 2px,面板上不可察觉。
  • 中性 border 加投影的配对现被 ui-theme elevation spec 拒绝,新的高层级表面必须选用 elevation token;规则记录于 docs/web-styling.md。
  • 输入框的大片 lv2 变为描边加收紧的辉光;深色描边经重绑保留 figma 低一档取值。
  • --dsw-shadow-lv1/lv1-blur 目前无消费方,lv2/lv3 只剩有意保留者;该阶为反色与定制表面继续存在。