From b873b9321e7512adaa70738fe2be344f28777621 Mon Sep 17 00:00:00 2001 From: "yx.zhang" Date: Tue, 1 Sep 2026 19:15:56 +0800 Subject: [PATCH] fix(web): per-element elevation tokens and review sync Re-declare the derived elevation tokens on body * so per-surface --dsw-elevation-stroke-color rebinds reach the consuming shadow (custom properties inherit with var() already substituted); pin that mechanism and add synthetic rejection cases to the stylesheet scans; take the ring-track basenames through node:path so the exemption matches on Windows; update the ModelsSection row-card spec to the hairline recipe; align the elevation note with the shipped l1 menu rebind, refresh the feedback-popover note's surface recipe, and document the soft tier. --- ...-13-feedback-note-editor-popover.i18n.yaml | 4 +- ...2026-08-13-feedback-note-editor-popover.md | 2 +- ...6-08-13-feedback-note-editor-popover.zh.md | 2 +- ...-01-web-elevation-stroke-shadows.i18n.yaml | 4 +- ...2026-09-01-web-elevation-stroke-shadows.md | 2 +- ...6-09-01-web-elevation-stroke-shadows.zh.md | 2 +- docs/web-styling.i18n.yaml | 4 +- docs/web-styling.md | 2 +- docs/web-styling.zh.md | 2 +- .../tests/styles.client.spec.ts | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../src/styles/gradient-shadow-text.css | 15 +- .../tests/corner-shape-styles.client.spec.ts | 33 ++-- .../tests/elevation-styles.client.spec.ts | 162 +++++++++++++----- 16 files changed, 167 insertions(+), 77 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml index c7289d869f..ac3e62bf89 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md -2026-08-13-feedback-note-editor-popover.md: 42c08e39cfb054db689503e23306c5049a97b6cb -2026-08-13-feedback-note-editor-popover.zh.md: 553029f8a42dae42a38e909d716b41e2c6dd252e +2026-08-13-feedback-note-editor-popover.md: 8f51f090cc24292ad02d96fefb1f45bc05df08c9 +2026-08-13-feedback-note-editor-popover.zh.md: e64e95ac6599be2d7c343b4432415f0e99c6ef9b diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md index 42c08e39cf..8f51f090cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md @@ -18,7 +18,7 @@ The note editor does not enter the row's flex layout at all. It is a popover: a **The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward. -**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`). +**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card surface recipe (`--dsw-specific-menu`, the `--dsw-elevation-prominent` shadow with the `--dsw-alias-border-l1` stroke rebind and `border: 0`) at `border-radius: 12px`. **Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md index 553029f8a4..e64e95ac65 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md @@ -18,7 +18,7 @@ Status: implemented **操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。 -**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。 +**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的表面配方(`--dsw-specific-menu`、`--dsw-elevation-prominent` 投影配 `--dsw-alias-border-l1` 描边重绑与 `border: 0`),圆角取 `border-radius: 12px`。 **失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。 diff --git a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.i18n.yaml index 04bab8384f..fa75bdb9ba 100644 --- a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md -2026-09-01-web-elevation-stroke-shadows.md: 9d5789038267d4fc5693cc46bb8870a4410ce080 -2026-09-01-web-elevation-stroke-shadows.zh.md: 9ad34a6f15098710ee4cee34142c95c4c60e27fe +2026-09-01-web-elevation-stroke-shadows.md: 774c9d996958cbc46cb21daaed928f9386c33421 +2026-09-01-web-elevation-stroke-shadows.zh.md: cb6dd3563952d97725d5e451c56e7fa7a710abaf diff --git a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md index 9d57890382..774c9d9969 100644 --- a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md +++ b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md @@ -12,7 +12,7 @@ Elevated web-client surfaces — menus, popovers, modals, panels, floating butto `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, and every menu-fill surface (`--dsw-specific-menu` background) rebinds the lighter `--dsw-alias-border-l2` so dropdown menus keep a quieter stroke than panels and buttons. +- `--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, and every menu-fill surface (`--dsw-specific-menu` background) and the composer rebind the lightest `--dsw-alias-border-l1`, 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. diff --git a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md index 9ad34a6f15..cb6dd35639 100644 --- a/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md +++ b/.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md @@ -12,7 +12,7 @@ Web 客户端的高层级表面——菜单、浮层、对话框、面板、悬 `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-elevation-stroke-color`——发丝描边颜色,默认 `--dsw-alias-border-l4`(浅色黑 16%、深色白 20%);组件可按表面或状态重绑:所有菜单面(`--dsw-specific-menu` 背景)与输入框重绑最浅的 `--dsw-alias-border-l1`,两者都比面板与按钮安静。默认色只声明在 `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——更大模糊、更低透明度——用于输入框。 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 536aabb6f9..c9e35b8d5f 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md -web-styling.md: 1fa79acba6289f7364846909a996f504785ff628 -web-styling.zh.md: 126011f0c8e4ff6832daa46e3c5096899a632171 +web-styling.md: dd057a3121422e4decfac06b9a3cf57e52254011 +web-styling.zh.md: 5ec0b65390b29e915fe3da633bb7c56ef92e17d6 diff --git a/docs/web-styling.md b/docs/web-styling.md index 1fa79acba6..dd057a3121 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -20,7 +20,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches. - Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. -- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)` or `var(--dsw-elevation-prominent)`: the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. +- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 126011f0c8..5ec0b65390 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -20,7 +20,7 @@ - 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。 - 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 -- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)` 或 `var(--dsw-elevation-prominent)`:0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 +- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 ## 变更系统 diff --git a/packages/client/ui-settings-models/tests/styles.client.spec.ts b/packages/client/ui-settings-models/tests/styles.client.spec.ts index 526a2a5b07..16a698de5f 100644 --- a/packages/client/ui-settings-models/tests/styles.client.spec.ts +++ b/packages/client/ui-settings-models/tests/styles.client.spec.ts @@ -57,7 +57,7 @@ describe('ModelsSection theme styles', () => { // under the dark theme, so filling the row with either erases the nested // editor's boundary. The row is outlined; the fill is the editor's alone. expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)') - expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)') + expect(block('.rowCard')).toContain('border: 0.5px solid var(--dsw-alias-border-l4)') expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 23b6f81209..b1ef34ea90 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: 756175ba1208cdca4c92c73885bad3c7d11e0065 -README.zh.md: 29a901020dd96c5b75e5bd1b486e4167e7d49b08 +README.md: c591501e274de0419ea3585feb731802985ac95b +README.zh.md: e970141963cc592541e77d748c421e693cc9568f diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 756175ba12..c591501e27 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -55,7 +55,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou `corner-shape.css` smooths every rounded corner: inside `@supports (corner-shape: superellipse(1.5))` it defines `--dsw-corner-shape` and applies it to all elements and their `::before`/`::after` through the universal selector, so engines without `corner-shape` keep circular corners. Full-round shapes — `border-radius: 50%` circles and pill radii — pair `corner-shape: round` with their radius in the owning component sheet because a superellipse deforms them; the corner-shape stylesheet spec enforces that pairing across every package stylesheet ([corner-smoothing note](../../../.agents/notes/implemented/feature/2026-09-01-web-superellipse-corner-smoothing.md)). -`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`) and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent` layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline ([elevation note](../../../.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md)). +`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`) and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft` (the composer's larger-blur, lower-alpha tier) layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline; the derived tokens are re-declared per element so a surface's stroke-color rebind takes effect ([elevation note](../../../.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.md)). ### Scrollbar rebinding diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 29a901020d..e970141963 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" `corner-shape.css` 平滑所有圆角:在 `@supports (corner-shape: superellipse(1.5))` 内定义 `--dsw-corner-shape`,并通过通配选择器应用到所有元素及其 `::before`/`::after`,因此不支持 `corner-shape` 的引擎保持普通圆弧。正圆形状——`border-radius: 50%` 的圆与胶囊半径——因超级椭圆会使其变形,须在所属组件样式表中把 `corner-shape: round` 与半径声明配对;corner-shape 样式表 spec 跨全部包样式表强制这一配对([圆角平滑笔记](../../../.agents/notes/implemented/feature/2026-09-01-web-superellipse-corner-smoothing.zh.md))。 -`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文档变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent` 在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不再有占布局的轮廓([elevation 笔记](../../../.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md))。 +`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文档变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft`(输入框专用的更大模糊、更低透明度档)在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不再有占布局的轮廓;派生 token 逐元素重声明,使表面对描边色的重绑真实生效([elevation 笔记](../../../.agents/notes/implemented/feature/2026-09-01-web-elevation-stroke-shadows.zh.md))。 ### 滚动条重新绑定 diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index b9e6eb25cd..5965bd5ce6 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -12,8 +12,19 @@ body { 柔光几乎不可见,由描边承担分离。描边颜色经 --dsw-elevation-stroke-color 间接,组件可重绑(菜单面与输入框重绑最浅的 l1,输入框的 workspace-trigger 态置 transparent 只保留柔光)。soft 供输入框:更大 - 模糊、更低透明度的柔光。 */ + 模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承 + 传给真正消费投影的后代。 */ --dsw-elevation-stroke-color: var(--dsw-alias-border-l4); + /* blur filter */ + --dsw-mask-blur: blur(2px); +} + +/* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就 + 已替换完 var() 的值,组件重绑 --dsw-elevation-stroke-color 将无法进入 + var(--dsw-elevation-*);逐元素声明让每个元素按自己看到的描边色重新替换 + (同 scrollbar.css 对 --dsh-scrollbar-thumb 的处理)。 */ +body, +body * { --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color); --dsw-elevation-panel: var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.03), 0 0 16px 0 rgba(0, 0, 0, 0.02); @@ -21,8 +32,6 @@ body { var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05); --dsw-elevation-soft: var(--dsw-elevation-stroke), 0 4px 16px 0 rgba(0, 0, 0, 0.03), 0 0 24px 0 rgba(0, 0, 0, 0.03); - /* blur filter */ - --dsw-mask-blur: blur(2px); } body[data-ds-dark-theme] { diff --git a/packages/client/ui-theme/tests/corner-shape-styles.client.spec.ts b/packages/client/ui-theme/tests/corner-shape-styles.client.spec.ts index b4569aeff8..fd9a5bd1e3 100644 --- a/packages/client/ui-theme/tests/corner-shape-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/corner-shape-styles.client.spec.ts @@ -59,22 +59,33 @@ describe('corner-shape.css smoothing', () => { }) }) +/** + * Full-round rules missing the `corner-shape: round` pairing. + * @param css - stylesheet text. + * @returns the offending selectors, in source order. + */ +function unpairedFullRound(css: string): string[] { + return parseRules(css) + .filter(rule => rule.declarations + .some(([property, value]) => property === 'border-radius' && isFullRound(value))) + .filter(rule => !rule.declarations + .some(([property, value]) => property === 'corner-shape' && value === 'round')) + .map(rule => rule.selectors.join(', ')) +} + describe('full-round radii keep circular corners', () => { + it('rejects a full-round radius without the pairing', () => { + expect(unpairedFullRound('.a { border-radius: 50%; }')).toEqual(['.a']) + expect(unpairedFullRound('.a { border-radius: 999px; }')).toEqual(['.a']) + expect(unpairedFullRound('.a { border-radius: 50%; corner-shape: round; }')).toEqual([]) + }) + it('pairs corner-shape: round with every full-round border-radius under packages/', () => { // The universal superellipse reaches every element, so each circle and // pill states its own arc back; a new one without the pairing regresses // silently on supporting engines only, which no jsdom test renders. - const unpaired: string[] = [] - for (const file of packageStylesheets()) { - for (const rule of parseRules(readFileSync(file, 'utf8'))) { - const fullRound = rule.declarations - .some(([property, value]) => property === 'border-radius' && isFullRound(value)) - if (!fullRound) continue - const paired = rule.declarations - .some(([property, value]) => property === 'corner-shape' && value === 'round') - if (!paired) unpaired.push(`${file} ${rule.selectors.join(', ')}`) - } - } + const unpaired = packageStylesheets().flatMap(file => + unpairedFullRound(readFileSync(file, 'utf8')).map(selectors => `${file} ${selectors}`)) expect(unpaired).toEqual([]) }) }) diff --git a/packages/client/ui-theme/tests/elevation-styles.client.spec.ts b/packages/client/ui-theme/tests/elevation-styles.client.spec.ts index d70b265a00..bf246f2d10 100644 --- a/packages/client/ui-theme/tests/elevation-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/elevation-styles.client.spec.ts @@ -8,6 +8,7 @@ * example the warn approval panels) stay real borders and are out of scope. */ import { readFileSync } from 'node:fs' +import { basename } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { packageStylesheets, parseRules } from './stylesheet-scan.ts' @@ -23,44 +24,120 @@ const sheetCss = readFileSync( fileURLToPath(new URL('../src/styles/gradient-shadow-text.css', import.meta.url)), 'utf8') describe('elevation tokens', () => { - const declarations = new Map(parseRules(sheetCss) - .filter(rule => rule.selectors.includes('body')) + const rules = parseRules(sheetCss) + const bodyOnly = new Map(rules + .filter(rule => rule.selectors.length === 1 && rule.selectors[0] === 'body') + .flatMap(rule => rule.declarations)) + const perElement = new Map(rules + .filter(rule => rule.selectors.includes('body *')) .flatMap(rule => rule.declarations)) - it('draws the hairline stroke at 0.5px through the rebindable color indirection', () => { - expect(declarations.get(STROKE_COLOR)).toBe('var(--dsw-alias-border-l4)') - expect(declarations.get('--dsw-elevation-stroke')).toBe(`0 0 0 0.5px var(${STROKE_COLOR})`) + it('defaults the stroke color on body alone, so a surface rebind inherits', () => { + // Declared per element, `body *` would beat inheritance on every + // descendant and a surface's rebind could not reach the box that carries + // the shadow; declared on body alone, the rebind inherits down. + expect(bodyOnly.get(STROKE_COLOR)).toBe('var(--dsw-alias-border-l4)') + expect(perElement.has(STROKE_COLOR)).toBe(false) }) - it('layers panel and prominent on top of the stroke', () => { + it('declares the derived values per element, so a stroke rebind takes effect', () => { + // A custom property computes with var() already substituted, and + // descendants inherit that computed value: derived tokens declared only on + // body would bake in body's stroke color, making every + // --dsw-elevation-stroke-color rebind a no-op. Per-element declarations + // re-substitute against the color each element sees (the same contract + // scrollbar.css states for --dsh-scrollbar-thumb). + expect(perElement.get('--dsw-elevation-stroke')).toBe(`0 0 0 0.5px var(${STROKE_COLOR})`) for (const name of ['--dsw-elevation-panel', '--dsw-elevation-prominent', '--dsw-elevation-soft']) { - expect(declarations.get(name), name).toMatch(/^var\(--dsw-elevation-stroke\), 0 /) + expect(perElement.get(name), name).toMatch(/^var\(--dsw-elevation-stroke\), 0 /) + expect(bodyOnly.has(name), name).toBe(false) } }) }) +/** + * Rules pairing an lv/elevation box-shadow with a neutral-border-token border. + * @param css - stylesheet text. + * @returns the offending selectors, in source order. + */ +function neutralBordersBesideElevation(css: string): string[] { + return parseRules(css) + .filter(rule => rule.declarations + .some(([property, value]) => property === 'box-shadow' && ELEVATED_SHADOW.test(value))) + .filter(rule => rule.declarations.some(([property, value]) => + property.startsWith('border') && !property.startsWith('border-radius') && NEUTRAL_BORDER.test(value))) + .map(rule => rule.selectors.join(', ')) +} + describe('elevated surfaces carry no neutral border', () => { + it('rejects a rule that pairs the shadow with a neutral border', () => { + expect(neutralBordersBesideElevation( + '.a { box-shadow: var(--dsw-elevation-panel); border: 0.5px solid var(--dsw-alias-border-l2); }', + )).toEqual(['.a']) + expect(neutralBordersBesideElevation( + '.a { box-shadow: var(--dsw-elevation-panel); border: 0; }', + )).toEqual([]) + }) + it('never pairs an lv/elevation shadow with a neutral border token under packages/', () => { // A 1px border beside the elevation stroke double-draws the outline and // shifts layout by the border width; the hairline belongs to the shadow. - const paired: string[] = [] - for (const file of packageStylesheets()) { - for (const rule of parseRules(readFileSync(file, 'utf8'))) { - const elevated = rule.declarations - .some(([property, value]) => property === 'box-shadow' && ELEVATED_SHADOW.test(value)) - if (!elevated) continue - const neutralBorder = rule.declarations.some(([property, value]) => - property.startsWith('border') && !property.startsWith('border-radius') && NEUTRAL_BORDER.test(value)) - if (neutralBorder) paired.push(`${file} ${rule.selectors.join(', ')}`) - } - } + const paired = packageStylesheets().flatMap(file => + neutralBordersBesideElevation(readFileSync(file, 'utf8')) + .map(selectors => `${file} ${selectors}`)) expect(paired).toEqual([]) }) }) +/** Border properties that carry a width in their shorthand. */ +const BORDER_EDGE = /^border(?:-top|-bottom|-left|-right)?$/ + +/** + * Solid neutral-token borders wider than the 0.5px hairline. The width test is + * lexical and order-sensitive: `border: solid 0.5px …` would be reported (a + * loud false positive to normalize), while split `border-width`/`border-color` + * declarations fall outside BORDER_EDGE and are not seen; no sheet under test + * writes either form. + * @param css - stylesheet text. + * @param exempt - ` ` pairs allowed to keep their width. + * @returns the offending ` : ` lines, in source order. + */ +function wideNeutralBorders(css: string, exempt: Set = new Set()): string[] { + const wide: string[] = [] + for (const rule of parseRules(css)) { + for (const [property, value] of rule.declarations) { + if (!BORDER_EDGE.test(property)) continue + if (!value.includes('solid') || !NEUTRAL_BORDER.test(value)) continue + if (value.startsWith('0.5px ')) continue + if (rule.selectors.some(selector => exempt.has(selector))) continue + wide.push(`${rule.selectors.join(', ')} ${property}: ${value}`) + } + } + return wide +} + +/** + * Filled divider lines (a border-token background on a 1px-tall or 1px-wide + * box) that keep the pre-hairline weight. + * @param css - stylesheet text. + * @returns the offending ` : ` lines, in source order. + */ +function wideFilledDividers(css: string): string[] { + const wide: string[] = [] + for (const rule of parseRules(css)) { + const paintsLine = rule.declarations.some(([property, value]) => + (property === 'background' || property === 'background-color') && NEUTRAL_BORDER.test(value)) + if (!paintsLine) continue + for (const [property, value] of rule.declarations) { + if ((property === 'height' || property === 'width') && value === '1px') { + wide.push(`${rule.selectors.join(', ')} ${property}: ${value}`) + } + } + } + return wide +} + describe('neutral solid borders are hairlines', () => { - /** Border properties that carry a width in their shorthand. */ - const BORDER_EDGE = /^border(?:-top|-bottom|-left|-right)?$/ /** * Spinner ring tracks, keyed ` `: the border is the * drawn graphic (a rotating ring), not an outline, so it keeps its width. @@ -70,22 +147,26 @@ describe('neutral solid borders are hairlines', () => { 'TrajectoryTable.module.css .historyLoadingSpinner', ]) + it('rejects a wide neutral border and a wide filled divider', () => { + expect(wideNeutralBorders('.a { border: 1px solid var(--dsw-alias-border-l2); }')) + .toEqual(['.a border: 1px solid var(--dsw-alias-border-l2)']) + expect(wideNeutralBorders('.a { border: 0.5px solid var(--dsw-alias-border-l2); }')).toEqual([]) + expect(wideFilledDividers('.a { background: var(--dsw-alias-border-l2); height: 1px; }')) + .toEqual(['.a height: 1px']) + expect(wideFilledDividers('.a { background: var(--dsw-alias-border-l2); height: 0.5px; }')).toEqual([]) + }) + it('draws every solid neutral-token border at 0.5px under packages/', () => { // Buttons, inputs, cards, and separators share the hairline weight; // dashed affordances and state-colored borders are out of scope. - const wide: string[] = [] - for (const file of packageStylesheets()) { - const base = file.slice(file.lastIndexOf('/') + 1) - for (const rule of parseRules(readFileSync(file, 'utf8'))) { - for (const [property, value] of rule.declarations) { - if (!BORDER_EDGE.test(property)) continue - if (!value.includes('solid') || !NEUTRAL_BORDER.test(value)) continue - if (value.startsWith('0.5px ')) continue - if (rule.selectors.some(selector => RING_TRACKS.has(`${base} ${selector}`))) continue - wide.push(`${file} ${rule.selectors.join(', ')} ${property}: ${value}`) - } - } - } + const wide = packageStylesheets().flatMap((file) => { + const base = basename(file) + const exempt = new Set([...RING_TRACKS] + .filter(track => track.startsWith(`${base} `)) + .map(track => track.slice(base.length + 1))) + return wideNeutralBorders(readFileSync(file, 'utf8'), exempt) + .map(line => `${file} ${line}`) + }) expect(wide).toEqual([]) }) @@ -94,19 +175,8 @@ describe('neutral solid borders are hairlines', () => { // background (menu separators, the conversation header seam, markdown hr, // vertical rails) — is the same hairline as a border. Visually-hidden 1px // clip boxes carry no border-token background and stay exempt. - const wide: string[] = [] - for (const file of packageStylesheets()) { - for (const rule of parseRules(readFileSync(file, 'utf8'))) { - const paintsLine = rule.declarations.some(([property, value]) => - (property === 'background' || property === 'background-color') && NEUTRAL_BORDER.test(value)) - if (!paintsLine) continue - for (const [property, value] of rule.declarations) { - if ((property === 'height' || property === 'width') && value === '1px') { - wide.push(`${file} ${rule.selectors.join(', ')} ${property}: ${value}`) - } - } - } - } + const wide = packageStylesheets().flatMap(file => + wideFilledDividers(readFileSync(file, 'utf8')).map(line => `${file} ${line}`)) expect(wide).toEqual([]) }) })