From fc45db811f1b2ea70d17c859ddbd0b9dbb9f778f Mon Sep 17 00:00:00 2001 From: vastsa Date: Wed, 16 Sep 2026 02:48:56 +0800 Subject: [PATCH] fix(theme): tokenize the remaining chrome surfaces and make the exemptions explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of issue #341 (vastsa/PI-Desktop#341): the prose reading surface, code-card chrome, scrims, Mermaid canvas, tool output, disabled send chip, and composer placeholders were still pinned by `:root[data-theme="…"]` literals that raised specificity above the base rule *and* skipped the variable, so no contributed theme could reach them. - 11 new tokens in tokens.css, each defaulting to the literal (or literal mix) the sheet painted before, so the built-in light/dark paint is unchanged. - Prose and transcript ink mixes now read `--ds-text-primary`, whose light value was the hardcoded `#1a1c1f`; four light overrides became pure duplicates and are gone, and the keycap ink moved into `--ds-prose-kbd-fg`. - The Shiki `one-dark-pro` / `one-light` plate and its ink stay with the Shiki theme (they are emitted as inline colours on the highlighted markup), so they are exempted by value with the reason recorded instead of being rewritten. - scripts/style-surface-tokens.mjs now holds every colour declaration inside a `:root[data-theme]` rule plus the base rules of the migrated families to the token contract, and enumerates its exemptions (Shiki palette, translucent shadow values, the ⌘K veil) so no gap is invisible. - Kept the light-qualified `.tool-row-content` rule: at (0,3,0) it is what keeps light from tinting error output and giving plain tool blocks a fill. Removing it would have been a silent paint change in light only. - §6.4 (en/zh) records the new surface-layer rows, the convention, and the exemptions; E2E-078's probe now pins 19 surfaces, including a plain/error tool-row cascade pair, against paint sampled from the pre-change app. Test assertions updated because they encoded the old literals or the deleted overrides: surface-polish (light `.overlay` scrim), markdown-prose-style (light link underline, inline-code chip, blockquote ink), plugins-page-style (light plugin backdrop), mermaid-rendering (light diagram canvas) — each now asserts the token and its light value, so the intent survives and the coverage is stronger. --- apps/desktop/src/styles/chat-shell.css | 4 - apps/desktop/src/styles/composer-menus.css | 6 +- apps/desktop/src/styles/composer.css | 21 +-- apps/desktop/src/styles/messages.css | 2 +- apps/desktop/src/styles/overlays.css | 6 + apps/desktop/src/styles/plugins.css | 6 +- apps/desktop/src/styles/prose.css | 55 +++---- apps/desktop/src/styles/theme-overrides.css | 19 ++- apps/desktop/src/styles/tokens.css | 32 ++++ apps/desktop/src/styles/ui-kit.css | 7 +- .../test/markdown-prose-style.test.mjs | 11 +- apps/desktop/test/mermaid-rendering.test.mjs | 7 +- apps/desktop/test/plugins-page-style.test.mjs | 10 +- apps/desktop/test/surface-polish.test.mjs | 8 +- .../test/theme-surface-tokens.test.mjs | 70 ++++++++- docs/spec/04-ux/07-ui-design-system.md | 70 +++++++-- docs/spec/06-delivery/04-e2e-test-plan.md | 15 +- docs/zh-CN/spec/04-ux/07-ui-design-system.md | 52 ++++++- .../spec/06-delivery/04-e2e-test-plan.md | 14 +- scripts/e2e-theme-surfaces.mjs | 6 +- scripts/e2e/theme-surfaces.js | 46 +++++- scripts/style-surface-tokens.mjs | 146 ++++++++++++++++-- 22 files changed, 494 insertions(+), 119 deletions(-) diff --git a/apps/desktop/src/styles/chat-shell.css b/apps/desktop/src/styles/chat-shell.css index 460a7afc4f..37f022ed51 100644 --- a/apps/desktop/src/styles/chat-shell.css +++ b/apps/desktop/src/styles/chat-shell.css @@ -627,10 +627,6 @@ text-align: center; } -:root[data-theme="light"] .empty-hero h1 { - color: #1a1c1f; -} - .empty-hero .home-project-switcher { display: inline-block; max-width: 100%; diff --git a/apps/desktop/src/styles/composer-menus.css b/apps/desktop/src/styles/composer-menus.css index 35f784a0a1..f037f3e35a 100644 --- a/apps/desktop/src/styles/composer-menus.css +++ b/apps/desktop/src/styles/composer-menus.css @@ -15,9 +15,9 @@ :root[data-theme="dark"] .composer-input::placeholder, :root[data-theme="dark"] .composer-placeholder { - /* Codex dark placeholder ~42% white */ - color: color-mix(in oklab, var(--gray-0) 42%, transparent); - -webkit-text-fill-color: color-mix(in oklab, var(--gray-0) 42%, transparent); + /* Codex dark placeholder ink (--ds-placeholder-ink). */ + color: var(--ds-placeholder-ink); + -webkit-text-fill-color: var(--ds-placeholder-ink); opacity: 1; } diff --git a/apps/desktop/src/styles/composer.css b/apps/desktop/src/styles/composer.css index ac81f2cbc1..0a57d4675d 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -452,19 +452,20 @@ .send-btn:disabled { opacity: 1; - background: color-mix(in oklab, var(--ds-text-primary) 18%, transparent); - color: color-mix(in oklab, var(--ds-bg-primary) 92%, transparent); + background: var(--ds-send-disabled-bg); + color: var(--ds-send-disabled-fg); } +/* + `.send-btn:disabled` scores (0,2,0), but light paints the *enabled* chip from + a `:root[data-theme="light"] .send-btn` rule at (0,3,0), which would strand the + disabled state on the near-black enabled fill. The disabled state therefore + keeps a light-qualified companion — it reads the same tokens, so a contributed + theme can still move the chip, and the built-in paint is unchanged. +*/ :root[data-theme="light"] .send-btn:disabled { - /* Gold empty-home disabled send ~#8e8e90 solid chip + white arrow */ - background: #8e8e90; - color: #ffffff; -} - -:root[data-theme="dark"] .send-btn:disabled { - background: color-mix(in oklab, var(--gray-0) 18%, transparent); - color: color-mix(in oklab, var(--gray-900) 70%, transparent); + background: var(--ds-send-disabled-bg); + color: var(--ds-send-disabled-fg); } .stop-btn { diff --git a/apps/desktop/src/styles/messages.css b/apps/desktop/src/styles/messages.css index d5ae5d5b43..4860591116 100644 --- a/apps/desktop/src/styles/messages.css +++ b/apps/desktop/src/styles/messages.css @@ -2923,7 +2923,7 @@ margin: 0; overflow: auto; border-radius: var(--radius-md); - background: var(--ds-tile); + background: var(--ds-tool-row-bg); padding: 10px 12px; color: var(--ds-text-secondary); font-family: var(--font-mono); diff --git a/apps/desktop/src/styles/overlays.css b/apps/desktop/src/styles/overlays.css index 415686f41d..46401e7997 100644 --- a/apps/desktop/src/styles/overlays.css +++ b/apps/desktop/src/styles/overlays.css @@ -7,6 +7,12 @@ /* Global search (Codex-style ⌘K spotlight): top-anchored dialog over a dimmed scrim. Same floating-surface family as .dialog; rows reuse the menu idiom. + + The ⌘K spotlight keeps one veil in both palettes. §8.4/D148 give the light + theme ~28% ink so white dialogs do not sit under a heavy veil; tokenizing this + value would either change built-in light paint or record a value the design + system does not own, so it stays a literal and is listed as an explicit + exemption in scripts/style-surface-tokens.mjs (issue #341). */ .search-overlay { position: fixed; diff --git a/apps/desktop/src/styles/plugins.css b/apps/desktop/src/styles/plugins.css index 3a79b1f191..41c90ce846 100644 --- a/apps/desktop/src/styles/plugins.css +++ b/apps/desktop/src/styles/plugins.css @@ -1742,14 +1742,10 @@ align-items: center; justify-content: center; padding: 24px; - background: color-mix(in oklab, var(--ds-bg-primary) 78%, transparent); + background: var(--ds-modal-veil); animation: plugins-fade-in var(--motion-duration-fast) var(--motion-ease-out) both; } -:root[data-theme="light"] .plugins-modal-backdrop { - background: color-mix(in oklab, #1a1c1f 32%, transparent); -} - .plugins-modal { display: flex; flex-direction: column; diff --git a/apps/desktop/src/styles/prose.css b/apps/desktop/src/styles/prose.css index 89169275c5..720eea2786 100644 --- a/apps/desktop/src/styles/prose.css +++ b/apps/desktop/src/styles/prose.css @@ -109,7 +109,7 @@ :root[data-theme="light"] .prose-chat h5, :root[data-theme="light"] .prose-chat h6 { - color: color-mix(in oklab, #1a1c1f 62%, transparent); + color: color-mix(in oklab, var(--ds-text-primary) 62%, transparent); } .prose-chat h3 { @@ -174,7 +174,7 @@ } :root[data-theme="light"] .prose-chat li::marker { - color: color-mix(in oklab, #1a1c1f 40%, transparent); + color: color-mix(in oklab, var(--ds-text-primary) 40%, transparent); } .prose-chat li > p { @@ -227,7 +227,7 @@ } :root[data-theme="light"] .prose-chat blockquote { - color: color-mix(in oklab, #1a1c1f 72%, transparent); + color: color-mix(in oklab, var(--ds-text-primary) 72%, transparent); } .prose-chat blockquote > :first-child { @@ -271,13 +271,14 @@ border-radius: var(--radius-3xs); } +/* Light only softens the permanent underline; the base rule already paints the + `--ds-text-primary` ink, so the colour line is not duplicated here. */ :root[data-theme="light"] .prose-chat a { - color: var(--ds-text-primary); - text-decoration-color: color-mix(in oklab, #1a1c1f 30%, transparent); + text-decoration-color: color-mix(in oklab, var(--ds-text-primary) 30%, transparent); } :root[data-theme="light"] .prose-chat a:hover { - text-decoration-color: color-mix(in oklab, #1a1c1f 80%, transparent); + text-decoration-color: color-mix(in oklab, var(--ds-text-primary) 80%, transparent); } .prose-chat img, @@ -297,14 +298,10 @@ line-height: var(--leading-normal); border-radius: var(--radius-3xs); background: var(--ds-tile-deep); - color: var(--ds-text-secondary); + color: var(--ds-prose-kbd-fg); white-space: nowrap; } -:root[data-theme="light"] .prose-chat kbd { - color: #303030; -} - /* Inline code: borderless soft chip — the hairline frame used to add noise around every token in dense technical answers. */ .prose-chat code { @@ -318,8 +315,8 @@ } :root[data-theme="light"] .prose-chat code { - background: color-mix(in oklab, #1a1c1f 6%, transparent); - color: #1a1c1f; + background: color-mix(in oklab, var(--ds-text-primary) 6%, transparent); + color: var(--ds-text-primary); } /* Fallback for a bare
 that bypassed the code-block chrome */
@@ -336,6 +333,8 @@
   color: #abb2bf;
 }
 
+/* Shiki one-light plate + ink. The Shiki theme owns this pair
+   (apps/desktop/src/lib/shiki.ts); see the guard header. */
 :root[data-theme="light"] .prose-chat pre {
   background: #fafafa;
   box-shadow: none;
@@ -361,10 +360,6 @@
   border-radius: var(--radius-md-plus);
 }
 
-:root[data-theme="light"] .prose-chat th {
-  color: #1a1c1f;
-}
-
 .prose-chat .table-wrap table {
   margin: 0;
   width: 100%;
@@ -435,6 +430,7 @@
   color: #abb2bf;
 }
 
+/* Shiki one-light plate + ink, the same pair as the bare `pre` fallback. */
 :root[data-theme="light"] .code-block {
   --code-block-bg: #fafafa; /* one-light editor background */
   color: #383a42;
@@ -448,13 +444,10 @@
   min-height: 32px;
   padding: 3px 6px 3px 14px;
   /* The head is a slightly lighter band on the editor surface; no rule. */
-  background: color-mix(in oklab, #fff 4%, transparent);
-}
-
-:root[data-theme="light"] .code-block-head {
-  background: color-mix(in oklab, #1a1c1f 3.5%, transparent);
+  background: var(--ds-code-head-bg);
 }
 
+/* Shiki one-light token ink for the language rail and the copy button. */
 :root[data-theme="light"] .code-block-lang {
   color: color-mix(in oklab, #383a42 62%, transparent);
 }
@@ -464,9 +457,9 @@
   opacity: 0.9;
 }
 
+/* The hover fill is ours (--ds-code-hover-bg); the ink is the Shiki token. */
 :root[data-theme="light"] .code-block .code-copy-btn:hover {
   color: #383a42;
-  background: color-mix(in oklab, #1a1c1f 6%, transparent);
 }
 
 .code-block-lang {
@@ -500,7 +493,7 @@
 
 .code-block .code-copy-btn:hover {
   color: #d7dae0;
-  background: color-mix(in oklab, #fff 8%, transparent);
+  background: var(--ds-code-hover-bg);
 }
 
 .code-block .code-copy-btn.copied {
@@ -609,11 +602,7 @@
 
 .mermaid-block-body {
   min-width: 0;
-  background: color-mix(in oklab, var(--ds-bg-primary) 94%, var(--ds-text-primary));
-}
-
-:root[data-theme="light"] .mermaid-block-body {
-  background: #ffffff;
+  background: var(--ds-mermaid-canvas);
 }
 
 .mermaid-svg {
@@ -706,6 +695,7 @@
   box-shadow: none;
 }
 
+/* Shiki one-light plate + ink, the same pair as the code card above. */
 :root[data-theme="light"] .prose-chat .mermaid-source {
   background: #fafafa;
   color: #383a42;
@@ -732,12 +722,11 @@
 }
 
 :root[data-theme="light"] .thinking-prose {
-  color: color-mix(in oklab, #1a1c1f 58%, transparent);
+  color: color-mix(in oklab, var(--ds-text-primary) 58%, transparent);
 }
 
 :root[data-theme="light"] .thinking-prose code {
-  background: #f0f0f0;
-  color: color-mix(in oklab, #1a1c1f 68%, transparent);
+  color: color-mix(in oklab, var(--ds-text-primary) 68%, transparent);
 }
 
 .thinking-prose h1,
@@ -760,7 +749,7 @@
 }
 
 .thinking-prose code {
-  background: color-mix(in oklab, var(--ds-text-primary) 4.5%, transparent);
+  background: var(--ds-thinking-code-bg);
   color: var(--ds-text-secondary);
 }
 
diff --git a/apps/desktop/src/styles/theme-overrides.css b/apps/desktop/src/styles/theme-overrides.css
index 40b9dcec0f..afe5a46f1d 100644
--- a/apps/desktop/src/styles/theme-overrides.css
+++ b/apps/desktop/src/styles/theme-overrides.css
@@ -66,19 +66,28 @@
  * identical in both conversation states. */
 :root[data-theme="light"] .composer-toolbar .icon-btn,
 :root[data-theme="light"] .mode-chip {
-  color: color-mix(in oklab, #1a1c1f 84%, transparent) !important;
+  color: color-mix(in oklab, var(--ds-text-primary) 84%, transparent) !important;
 }
 
 :root[data-theme="light"] .composer-input::placeholder,
 :root[data-theme="light"] .composer-placeholder {
-  /* Gold empty-home placeholder ink ~#525355–#58595b */
-  color: #4a4c4f;
-  -webkit-text-fill-color: #4a4c4f;
+  /* Gold empty-home placeholder ink (--ds-placeholder-ink). */
+  color: var(--ds-placeholder-ink);
+  -webkit-text-fill-color: var(--ds-placeholder-ink);
   opacity: 1;
 }
 
+/*
+  This rule looks redundant next to the tokenized base rule, but it keeps the
+  cascade the light theme already had: at (0,3,0) it out-specifies
+  `.tool-row-content.is-error` (0,2,0) and ties with
+  `.tool-block.is-plain .tool-row-content` (0,3,0) by winning on import order.
+  Dropping it would tint error output and give plain tool blocks a fill in light
+  only. Reading the token keeps the built-in paint identical while letting a
+  contributed theme move the light tile (issue #341).
+*/
 :root[data-theme="light"] .tool-row-content {
-  background: color-mix(in oklab, #1a1c1f 2%, transparent);
+  background: var(--ds-tool-row-bg);
 }
 
 .footer-notification {
diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css
index 940f25bdac..3ba5afcd89 100644
--- a/apps/desktop/src/styles/tokens.css
+++ b/apps/desktop/src/styles/tokens.css
@@ -120,6 +120,25 @@
   --ds-switch-ring-off: color-mix(in oklab, var(--gray-0) 22%, transparent);
   --ds-switch-knob-off: var(--gray-300);
   --ds-switch-knob-on: var(--gray-900);
+  /*
+    Prose ink, code-card chrome, scrims, and transcript fills tokenized in
+    issue #341. Every default is the literal (or literal mix) the sheet painted
+    before, so the built-in appearance is unchanged; what changes is that a
+    contributed theme can now reach them. The Shiki syntax plate and its ink
+    stay with the Shiki theme (see scripts/style-surface-tokens.mjs).
+  */
+  --ds-prose-kbd-fg: var(--ds-text-secondary);
+  --ds-thinking-code-bg: color-mix(in oklab, var(--ds-text-primary) 4.5%, transparent);
+  --ds-mermaid-canvas: color-mix(in oklab, var(--ds-bg-primary) 94%, var(--ds-text-primary));
+  --ds-code-head-bg: color-mix(in oklab, #fff 4%, transparent);
+  --ds-code-hover-bg: color-mix(in oklab, #fff 8%, transparent);
+  /* D148: dark keeps the ~45% black veil. */
+  --ds-scrim: color-mix(in oklab, #000 45%, transparent);
+  --ds-modal-veil: color-mix(in oklab, var(--ds-bg-primary) 78%, transparent);
+  --ds-tool-row-bg: var(--ds-tile);
+  --ds-send-disabled-bg: color-mix(in oklab, var(--ds-text-primary) 18%, transparent);
+  --ds-send-disabled-fg: color-mix(in oklab, var(--gray-900) 70%, transparent);
+  --ds-placeholder-ink: color-mix(in oklab, var(--gray-0) 42%, transparent);
   --ds-success: #40c977;
   --ds-warning: #ff8549;
   --ds-error: #ff6764;
@@ -203,6 +222,19 @@
   --ds-settings-field-bg: #ffffff;
   --ds-settings-nav-active: color-mix(in oklab, #1a1c1f 12%, #ffffff);
   --ds-field-inset-bg: #f3f3f3;
+  /* Issue #341: the light values these fills used to hardcode in overrides. */
+  --ds-prose-kbd-fg: #303030;
+  --ds-thinking-code-bg: #f0f0f0;
+  --ds-mermaid-canvas: #ffffff;
+  --ds-code-head-bg: color-mix(in oklab, #1a1c1f 3.5%, transparent);
+  --ds-code-hover-bg: color-mix(in oklab, #1a1c1f 6%, transparent);
+  /* D148: light drops the scrim to ~28% ink so white dialogs stay readable. */
+  --ds-scrim: color-mix(in oklab, #1a1c1f 28%, transparent);
+  --ds-modal-veil: color-mix(in oklab, #1a1c1f 32%, transparent);
+  --ds-tool-row-bg: color-mix(in oklab, #1a1c1f 2%, transparent);
+  --ds-send-disabled-bg: #8e8e90; /* gold disabled send chip */
+  --ds-send-disabled-fg: #ffffff;
+  --ds-placeholder-ink: #4a4c4f;
   --ds-field-inset-focus-bg: #ffffff;
   /* Off knob stays white on the pale track; on knob keeps white on near-black. */
   --ds-switch-track-off: color-mix(in oklab, #1a1c1f 10%, transparent);
diff --git a/apps/desktop/src/styles/ui-kit.css b/apps/desktop/src/styles/ui-kit.css
index 5981411f7b..8685b16cd0 100644
--- a/apps/desktop/src/styles/ui-kit.css
+++ b/apps/desktop/src/styles/ui-kit.css
@@ -137,16 +137,11 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  background: color-mix(in oklab, #000 45%, transparent);
+  background: var(--ds-scrim);
   padding: 24px;
   animation: overlay-in var(--motion-duration-normal) var(--motion-ease-out) both;
 }
 
-:root[data-theme="light"] .overlay {
-  /* Lighter scrim keeps elevated white dialogs readable without a heavy veil. */
-  background: color-mix(in oklab, #1a1c1f 28%, transparent);
-}
-
 .dialog {
   width: min(100%, 440px);
   /* Overlay is a flex box; auto min-width would let wide content overflow it. */
diff --git a/apps/desktop/test/markdown-prose-style.test.mjs b/apps/desktop/test/markdown-prose-style.test.mjs
index 5207d9faa0..b83cb64a5b 100644
--- a/apps/desktop/test/markdown-prose-style.test.mjs
+++ b/apps/desktop/test/markdown-prose-style.test.mjs
@@ -108,14 +108,19 @@ test("light theme markdown uses paper-quiet surfaces", () => {
     would happily run past the closing brace and match a declaration from some
     later rule, so a stale assertion would keep passing after the CSS moved on.
   */
+  /*
+    The light ink mixes read `--ds-text-primary`, whose light value is the
+    #1a1c1f they used to hardcode: paint-identical, and now a contributed theme
+    can move the whole light ink tier at once (issue #341).
+  */
   // Links keep the underline from the base rule and only soften its colour here.
   assert.match(
     stylesSource,
-    /:root\[data-theme="light"\] \.prose-chat a\s*\{[^}]*text-decoration-color:\s*color-mix\(in oklab,\s*#1a1c1f 30%/,
+    /:root\[data-theme="light"\] \.prose-chat a\s*\{[^}]*text-decoration-color:\s*color-mix\(in oklab,\s*var\(--ds-text-primary\) 30%/,
   );
   assert.match(
     stylesSource,
-    /:root\[data-theme="light"\] \.prose-chat code\s*\{[^}]*background:\s*color-mix\(in oklab,\s*#1a1c1f 6%/,
+    /:root\[data-theme="light"\] \.prose-chat code\s*\{[^}]*background:\s*color-mix\(in oklab,\s*var\(--ds-text-primary\) 6%/,
   );
   // Blockquotes keep their muted ink in light; the tile fill comes from the
   // theme-agnostic base rule, so the override sets colour only (D297).
@@ -123,7 +128,7 @@ test("light theme markdown uses paper-quiet surfaces", () => {
     /:root\[data-theme="light"\] \.prose-chat blockquote\s*\{[^}]*\}/,
   );
   assert.ok(lightBlockquote, "light theme should still tune the blockquote ink");
-  assert.match(lightBlockquote[0], /color:\s*color-mix\(in oklab,\s*#1a1c1f 72%/);
+  assert.match(lightBlockquote[0], /color:\s*color-mix\(in oklab,\s*var\(--ds-text-primary\) 72%/);
   assert.doesNotMatch(lightBlockquote[0], /border|background:/);
   // No light-only table shell: the tiles are tokens and need no override.
   assert.doesNotMatch(
diff --git a/apps/desktop/test/mermaid-rendering.test.mjs b/apps/desktop/test/mermaid-rendering.test.mjs
index 642a47b6af..7171c9c09f 100644
--- a/apps/desktop/test/mermaid-rendering.test.mjs
+++ b/apps/desktop/test/mermaid-rendering.test.mjs
@@ -84,7 +84,12 @@ test("diagram chrome is bounded, theme-aware, and reduced-motion safe", () => {
     stylesSource,
     /\.mermaid-svg > svg\s*\{[\s\S]*?width: 100%;[\s\S]*?max-width: 100%/,
   );
-  assert.match(stylesSource, /:root\[data-theme="light"\] \.mermaid-block/);
+  // The diagram canvas is a token in both palettes (issue #341): light used to
+  // pin #ffffff in a `:root[data-theme="light"]` override, which out-specified
+  // the base rule and skipped the variable.
+  assert.match(stylesSource, /\.mermaid-block-body\s*\{[^}]*background:\s*var\(--ds-mermaid-canvas\)/);
+  assert.equal((stylesSource.match(/^\s*--ds-mermaid-canvas:/gm) ?? []).length, 2);
+  assert.doesNotMatch(stylesSource, /:root\[data-theme="light"\] \.mermaid-block-body/);
   assert.match(
     stylesSource,
     /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\.mermaid-loading > span/,
diff --git a/apps/desktop/test/plugins-page-style.test.mjs b/apps/desktop/test/plugins-page-style.test.mjs
index 22d8979037..9698272b7a 100644
--- a/apps/desktop/test/plugins-page-style.test.mjs
+++ b/apps/desktop/test/plugins-page-style.test.mjs
@@ -37,7 +37,15 @@ test("plugins page styles use design tokens in both themes", () => {
   assert.match(section, /\.plugins-search\s*\{[\s\S]*?--ds-text-primary/);
   assert.match(section, /\.plugins-modal\s*\{[\s\S]*?--ds-bg-elevated-opaque/);
   assert.match(section, /\.plugins-installed-mark\s*\{[\s\S]*?--ds-success/);
-  assert.match(section, /:root\[data-theme="light"\] \.plugins-modal-backdrop/);
+  // The permission-review veil is a token now (issue #341): light's 32% ink sits
+  // in the light token block instead of a literal `:root[data-theme]` override,
+  // and dark reads the same token from the base rule.
+  assert.match(
+    section,
+    /\.plugins-modal-backdrop\s*\{[^}]*background:\s*var\(--ds-modal-veil\)/,
+  );
+  assert.equal((stylesSource.match(/^\s*--ds-modal-veil:/gm) ?? []).length, 2);
+  assert.doesNotMatch(stylesSource, /:root\[data-theme="light"\] \.plugins-modal-backdrop/);
   // D296: the header, title glyph and segmented control carry tone, not rules.
   assert.doesNotMatch(section, /\.plugins-page-header\s*\{[^}]*border-bottom/);
   assert.match(section, /\.plugins-title-icon\s*\{[^}]*background:\s*var\(--plugins-tile-deep\)/);
diff --git a/apps/desktop/test/surface-polish.test.mjs b/apps/desktop/test/surface-polish.test.mjs
index 1acf0dcda7..fd03db692f 100644
--- a/apps/desktop/test/surface-polish.test.mjs
+++ b/apps/desktop/test/surface-polish.test.mjs
@@ -91,10 +91,16 @@ test("settings and form controls gain light-theme surfaces", () => {
     /\.shortcut-keybinding kbd\s*\{[^}]*background:\s*var\(--ds-raised\)[^}]*box-shadow:\s*var\(--ds-raised-shadow\)/,
   );
   assert.doesNotMatch(styles, /\.shortcut-keybinding kbd\s*\{[^}]*border:/);
+  // The dialog scrim is a token now (issue #341). Its light value used to sit as
+  // a literal inside a `:root[data-theme="light"]` override, which raised
+  // specificity above the base rule *and* skipped the variable; the lighter
+  // light veil now lives in the light token block, so a theme can move it.
   assert.match(
     styles,
-    /:root\[data-theme="light"\]\s+\.overlay\s*\{[\s\S]*?background:\s*color-mix\(in oklab,\s*#1a1c1f 28%/,
+    /:root\[data-theme="light"\][\s\S]*?--ds-scrim:\s*color-mix\(in oklab,\s*#1a1c1f 28%/,
   );
+  assert.match(styles, /\.overlay\s*\{[^}]*background:\s*var\(--ds-scrim\)/);
+  assert.doesNotMatch(styles, /:root\[data-theme="light"\]\s+\.overlay\s*\{/);
 });
 
 test("switch on-track outranks the per-theme off-track", () => {
diff --git a/apps/desktop/test/theme-surface-tokens.test.mjs b/apps/desktop/test/theme-surface-tokens.test.mjs
index cdc4ba91a1..41ebee1779 100644
--- a/apps/desktop/test/theme-surface-tokens.test.mjs
+++ b/apps/desktop/test/theme-surface-tokens.test.mjs
@@ -14,14 +14,74 @@ test("surface guard catches literal fills on base, theme, and focus rules", () =
   assert.deepEqual(violations.map(({ line }) => line), [2, 3, 6]);
 });
 
-test("surface guard preserves tokens and excludes deferred prose, scrims, and shadows", () => {
-  assert.deepEqual(findLiteralSurfaceColors(`
+test("surface guard preserves tokens and the enumerated exemptions", () => {
+  assert.deepEqual(
+    findLiteralSurfaceColors(`
 :root { --ds-settings-field-bg: #fff; }
 /* .settings-search { background: #fff; } */
-.settings-search { background: var(--ds-settings-field-bg); box-shadow: 0 1px 2px #000; }
+.settings-search { background: var(--ds-settings-field-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); }
 .plugins-search:focus-visible { background: color-mix(in oklab, var(--ds-text-primary) 7%, transparent); }
 .settings-search-clear { background: #fff; }
-.plugins-modal-backdrop { background: #000; }
+.search-overlay { background: color-mix(in oklab, #000 45%, transparent); }
+.code-block-lang { color: color-mix(in oklab, #383a42 62%, transparent); }
 .prose { background: rgb(255, 255, 255); }
-`), []);
+`),
+    [],
+  );
+});
+
+test("surface guard holds every theme override to the token contract", () => {
+  /*
+    Issue #341: a literal inside a :root[data-theme] rule raises specificity
+    above the base token rule and does not read a variable, so that surface drops
+    out of every contributed theme's reach. The Shiki syntax palette, a
+    black-alpha shadow, and the token blocks themselves stay exempt.
+  */
+  const violations = findLiteralSurfaceColors(`
+:root[data-theme="light"] .prose-chat h5 { color: color-mix(in oklab, #1a1c1f 62%, transparent); }
+:root[data-theme="light"] .prose-chat pre { background: #fafafa; color: #383a42; }
+:root[data-theme="dark"] .overlay { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22); }
+:root[data-theme="dark"] .toast { box-shadow: 0 1px 2px rgba(20, 20, 20, 0.22); }
+:root[data-theme="light"] .code-block { --code-block-bg: #1a1c1f; }
+:root[data-theme="light"] { --ds-bg-primary: #ffffff; }
+:root[data-theme="light"] .plugins-search { background: var(--ds-field-inset-bg); }`);
+  assert.deepEqual(
+    violations.map(({ line, property }) => [line, property]),
+    [
+      [2, "color"],
+      [5, "box-shadow"],
+      [6, "--code-block-bg"],
+    ],
+  );
+});
+
+test("surface guard covers the tokenized families and every opaque colour form", () => {
+  /*
+    The migrated families are a fixed list that includes the code-card and
+    Mermaid variants tokenized in issue #341, and the checked property set spans
+    shorthand borders, background images, text shadows and filters — so a literal
+    cannot slip back in through a property nobody listed.
+  */
+  const violations = findLiteralSurfaceColors(`
+.code-block-head { background: #123456; }
+.mermaid-block-body { background: rgb(255, 255, 255); }
+.search-overlay .search-dialog { border: 1px solid #000; }
+.overlay { background-image: linear-gradient(#fff, #000); }
+.tool-row-content { text-shadow: 0 1px 2px #101010; }
+:root[data-theme="dark"] .prose-chat kbd { filter: drop-shadow(#336699 0 1px 2px); }
+:root[data-theme="dark"] .toast { box-shadow: 0 0 0 1px #ffffff; }
+.plugins-modal-backdrop { background: var(--ds-modal-veil); }
+:root[data-theme="light"] .mermaid-block-body { background: var(--ds-mermaid-canvas); }`);
+  assert.deepEqual(
+    violations.map(({ line, property }) => [line, property]),
+    [
+      [2, "background"],
+      [3, "background"],
+      [4, "border"],
+      [5, "background-image"],
+      [6, "text-shadow"],
+      [7, "filter"],
+      [8, "box-shadow"],
+    ],
+  );
 });
diff --git a/docs/spec/04-ux/07-ui-design-system.md b/docs/spec/04-ux/07-ui-design-system.md
index 354f319a43..48c0dc95b9 100644
--- a/docs/spec/04-ux/07-ui-design-system.md
+++ b/docs/spec/04-ux/07-ui-design-system.md
@@ -572,6 +572,13 @@ floating layers where an edge is an elevation cue rather than a partition.
 | Settings search | `--ds-settings-field-bg` (light `#ffffff`, dark `#212121`) | Search pill on the settings rail |
 | Active settings item | `--ds-settings-nav-active` (light 12% `#1a1c1f` mixed over white; dark 10% `--gray-0` over transparent) | Selected navigation pill |
 | Inset search | `--ds-field-inset-bg`, `--ds-field-inset-focus-bg` (light `#f3f3f3` / white; dark 5% / 7% primary-text mix over transparent) | Plugin search and Agent capability search, including focus |
+| Prose keycap / thinking code | `--ds-prose-kbd-fg` (light `#303030`, dark `--ds-text-secondary`), `--ds-thinking-code-bg` (light `#f0f0f0`, dark 4.5% text mix) | Keycap ink and the thinking-prose code chip |
+| Code-card chrome | `--ds-code-head-bg`, `--ds-code-hover-bg` (light 3.5% / 6% `#1a1c1f` over the plate; dark 4% / 8% white) | The `.code-block` head band and its hover fills. The Shiki syntax plate and its ink are deliberately not tokens — see below |
+| Mermaid canvas | `--ds-mermaid-canvas` (light `#ffffff`, dark 94% `--ds-bg-primary` + 6% text mix) | The diagram body inside `.mermaid-block` |
+| Scrim / veil | `--ds-scrim` (dark ~45% black, light ~28% `#1a1c1f`, D148), `--ds-modal-veil` (dark 78% `--ds-bg-primary`, light ~32% `#1a1c1f`) | The dialog scrim and the plugin permission backdrop |
+| Tool output | `--ds-tool-row-bg` (light 2% `#1a1c1f`, dark `--ds-tile`) | Tool result and error output blocks in the transcript |
+| Disabled send chip | `--ds-send-disabled-bg`, `--ds-send-disabled-fg` (light `#8e8e90` / `#ffffff`; dark 18% text mix / 70% `--gray-900`) | The composer's disabled send button |
+| Composer placeholder | `--ds-placeholder-ink` (light `#4a4c4f`, dark 42% white) | Input and placeholder ink in both composer states |
 
 The dark composer shell consumes `--ds-bg-elevated-primary` directly; light
 continues to use `--ds-bg-composer`. Switch on-state knobs consume
@@ -580,17 +587,58 @@ focus rings, and shadows while allowing a contributed stylesheet to override
 the variables. Theme-specific component rules may retain their existing shadow
 or layout differences, but must not replace a token-driven fill with a literal.
 
-Every surface colour the shell paints must come from a token. A
-`:root[data-theme="light"]` override that writes a literal raises specificity
-above the base token rule and does not read a variable, so it silently pins that
-surface out of every theme's reach — see D419.
-
-`pnpm lint` also guards `background` / `background-color` on the migrated
-settings rail, search, navigation item, switch thumb, capability search, plugin
-search, and composer-shell families against bare hex, CSS color functions,
-`white`, and `black`. The guard is intentionally scoped; it does not establish
-full color-token coverage of prose, scrims, other chrome, or plugin CSS. Real
-rendered theme checks remain necessary to verify cascade and focus behavior.
+Prose and transcript inks ride `--ds-text-primary`: the light overrides that
+used to hardcode `#1a1c1f` now mix the token, so the whole light ink tier moves
+with the theme (headings 5/6 at 62%, list markers 40%, quote ink 72%, link
+underlines 30% and 80% on hover, the inline-code chip at 6% with full-strength
+ink, thinking prose 58% and its code 68%).
+
+**Convention:** a `:root[data-theme="…"]` rule must not write a literal colour.
+Such a rule out-specifies the base token rule *and* never reads a variable, so it
+silently pins that surface out of every theme's reach — see D419. A
+theme-specific value belongs in the token blocks of `styles/tokens.css`, where
+both palettes define the same `--ds-*` name and the base rule reads it once.
+
+`pnpm lint` runs `scripts/style-surface-tokens.mjs`, which enforces that
+convention mechanically. Rule 1: inside a `:root[data-theme]` rule every colour
+declaration — `color`, `background`/`-color`/`-image`, `text-decoration-color`,
+`-webkit-text-fill-color`, `border` and its colour longhands, `outline`,
+`fill`, `stroke`, `accent-color`, `caret-color`, and `box-shadow` — must resolve
+through a custom property; the token blocks themselves are the place for
+literals, and a component-local custom property holding a literal colour is a
+violation too, because it shadows the root token. Rule 2: the base rules of the
+migrated chrome families — settings rail, search, navigation item, switch thumb,
+capability search, plugin search, composer shell, composer toolbar/chip/
+placeholder/input, plus prose, code-card (`code-block`, head band, language
+rail), Mermaid (block, body, head, title, error, source), scrims, tool rows,
+send button and empty hero — must not paint a literal either.
+
+Its exemptions are enumerated with reasons rather than left silent:
+
+- the `one-dark-pro` / `one-light` Shiki palette (the plate and its ink are
+  authored by the Shiki theme and emitted as inline colours on the highlighted
+  markup, so the pair has to move together — through the Shiki theme, not a CSS
+  token);
+- translucent black- or white-alpha shadow values (`box-shadow`, `text-shadow`,
+  `filter`), which only offset darkness; an opaque shadow colour is still
+  checked;
+- the ⌘K `.search-overlay` veil, which keeps the dark 45% black mix in both
+  palettes and so still departs from D148's lighter light veil. That one is a
+  maintainer decision recorded as a known gap: it is checked by the guard *and*
+  allowed there, so removing the allowance fails `pnpm lint`.
+
+Two boundaries stay open by design. The family rule is a fixed list, not every
+selector in the renderer, so it catches a regression in a migrated surface but
+not a brand-new literal on a selector nobody has reviewed. And the guard is
+static text: it cannot see a cascade conflict between two token-reading rules
+(#339's root cause). Both are why the rendered checks in
+`pnpm test:e2e:theme-surfaces` remain necessary — that probe compares the built
+paint of every migrated surface against values sampled from the pre-change app,
+so a token whose default is not exactly the old literal fails there. Keep the
+light-qualified `.tool-row-content` rule that probe pins: at (0,3,0) it
+out-specifies `.tool-row-content.is-error` and ties with
+`.tool-block.is-plain .tool-row-content`, so dropping it would tint error output
+and give plain tool blocks a fill in light only.
 
 | Context | Treatment |
 |---|---|
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index 685e1b5fc1..50c2177e91 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -4752,7 +4752,9 @@ identify the platform validation still needed.
   4. Hover file-tree rows or diff headers; focus the browser URL field.
   5. Open a confirmation/provider dialog and inspect the scrim.
   6. In both light and dark palettes, inspect the settings rail, search, selected
-     item, on-state knob, composer shell, and plugin/capability searches. Apply
+     item, on-state knob, composer shell, plugin/capability searches, the code
+     card's head band, the Mermaid canvas, tool output, the composer placeholder
+     and disabled send chip, and the dialog scrim and permission backdrop. Apply
      custom surface variables, keyboard-focus both searches, then remove the
      custom theme.
 - **Expected**:
@@ -4761,9 +4763,14 @@ identify the platform validation still needed.
   - Toggle on-state keeps a white knob on the near-black track.
   - Hover fills on file-tree/diff/resize ease with shared motion tokens.
   - Light dialog scrim is softer than the dark 45% veil (~28% ink).
-  - Custom variables repaint the corresponding fills and search focus states;
-    removing them restores the built-in 8-bit RGBA paint and existing shadows/
-    focus rings. This batch does not migrate prose or scrims or change plugin APIs.
+  - Tool output keeps its cascade: light paints the same lighter tile over error
+    output and over plain tool blocks, while dark shows the error tint and leaves
+    plain blocks transparent.
+  - Custom variables repaint the corresponding fills, keycap ink, and search
+    focus states; removing them restores the built-in 8-bit RGBA paint and the
+    existing shadows/focus rings. Prose ink mixes follow `--ds-text-primary`,
+    and the `one-dark-pro` / `one-light` Shiki plate and its ink stay with the
+    Shiki theme by design. This batch does not change plugin APIs.
 - **Specs linked**: `04-ux/07-ui-design-system.md`, `04-ux/08-component-spec.md`
 - **Acceptance**: D148
 - **Milestone**: M5
diff --git a/docs/zh-CN/spec/04-ux/07-ui-design-system.md b/docs/zh-CN/spec/04-ux/07-ui-design-system.md
index a6eb026014..b6950d425b 100644
--- a/docs/zh-CN/spec/04-ux/07-ui-design-system.md
+++ b/docs/zh-CN/spec/04-ux/07-ui-design-system.md
@@ -511,6 +511,13 @@ shadow-lg:  0 8px 24px rgba(0,0,0,0.12)
 | 设置搜索 | `--ds-settings-field-bg`(浅色 `#ffffff`、深色 `#212121`) | 导航轨搜索框 |
 | 设置选中项 | `--ds-settings-nav-active`(浅色为 12% `#1a1c1f` 混合白底;深色为 10% `--gray-0` 混合透明底) | 选中导航胶囊 |
 | 内嵌搜索 | `--ds-field-inset-bg`、`--ds-field-inset-focus-bg`(浅色 `#f3f3f3` / 白色;深色为 5% / 7% 主文本色混合透明底) | 插件搜索和 Agent 能力搜索,包含焦点状态 |
+| 正文键帽 / 思考代码 | `--ds-prose-kbd-fg`(浅色 `#303030`、深色 `--ds-text-secondary`)、`--ds-thinking-code-bg`(浅色 `#f0f0f0`、深色 4.5% 文字色混合) | 键帽墨色与思考区代码芯片 |
+| 代码卡外壳 | `--ds-code-head-bg`、`--ds-code-hover-bg`(浅色在代码底板上混合 3.5% / 6% `#1a1c1f`;深色 4% / 8% 白色) | `.code-block` 的标题带与悬停填充。Shiki 语法底板与其墨色刻意不设为标记 —— 见下 |
+| Mermaid 画布 | `--ds-mermaid-canvas`(浅色 `#ffffff`、深色 94% `--ds-bg-primary` + 6% 文字色混合) | `.mermaid-block` 内的图表主体 |
+| 遮罩 / 面纱 | `--ds-scrim`(深色约 45% 黑、浅色约 28% `#1a1c1f`,D148)、`--ds-modal-veil`(深色 78% `--ds-bg-primary`、浅色约 32% `#1a1c1f`) | 对话框遮罩与插件权限蒙层 |
+| 工具输出 | `--ds-tool-row-bg`(浅色 2% `#1a1c1f`、深色 `--ds-tile`) | 会话记录中的工具结果与错误输出块 |
+| 禁用发送芯片 | `--ds-send-disabled-bg`、`--ds-send-disabled-fg`(浅色 `#8e8e90` / `#ffffff`;深色 18% 文字色混合 / 70% `--gray-900`) | 输入框的禁用发送按钮 |
+| 输入占位符 | `--ds-placeholder-ink`(浅色 `#4a4c4f`、深色 42% 白) | 两种输入框状态的输入与占位墨色 |
 
 深色输入框壳直接使用 `--ds-bg-elevated-primary`,浅色继续使用
 `--ds-bg-composer`。开启状态的开关旋钮在两套调色板中均使用
@@ -518,13 +525,44 @@ shadow-lg:  0 8px 24px rgba(0,0,0,0.12)
 覆盖填充色。组件的主题专属规则可以保留既有阴影或布局差异,但不得用字面量
 覆盖原本读取变量的填充色。
 
-`pnpm lint` 会检查本批设置导航轨、搜索、导航项、开关旋钮、能力搜索、插件搜索
-与输入框壳的 `background` / `background-color`,拒绝裸 hex、CSS 颜色函数、
-`white` 和 `black`。此检查范围有限,不代表正文、遮罩、其他外壳或插件 CSS 已全部
-完成颜色标记迁移;级联和焦点行为仍需真实渲染验证。
-
-外壳绘制的任何表面色都必须来自标记。`:root[data-theme="light"]` 覆写里写字面量
-会抬高特异度、压过读标记的基础规则,等于把该表面钉死在所有主题之外 —— 见 D419。
+正文与会话记录的墨色统一走 `--ds-text-primary`:原先硬编码 `#1a1c1f` 的浅色覆写
+改为混合该标记,整套浅色墨阶随主题移动(5/6 级标题 62%、列表标记 40%、引用墨色
+72%、链接下划线 30% 与悬停 80%、内联代码芯片 6% 与满强度墨色、思考正文 58% 及其
+代码 68%)。
+
+**约定**:`:root[data-theme="…"]` 规则不得写字面量颜色。这类规则既抬高特异度
+压过读标记的基础规则,又完全不读变量,等于把该表面钉死在所有主题之外 —— 见
+D419。主题专属取值应写在 `styles/tokens.css` 的标记块里,两套调色板各定义同名
+`--ds-*`,由基础规则读取一次。
+
+`pnpm lint` 运行 `scripts/style-surface-tokens.mjs`,机械地守住这条约定。规则一:
+`:root[data-theme]` 规则内的每个颜色声明 —— `color`、`background`/`-color`/`-image`、
+`text-decoration-color`、`-webkit-text-fill-color`、`border` 及其颜色长写、`outline`、
+`fill`、`stroke`、`accent-color`、`caret-color` 以及 `box-shadow` —— 都必须经由自
+定义属性解析;标记块正是存放字面量的地方,而组件局部自定义属性若持有字面量颜色同样
+违规,因为它会遮蔽根标记。规则二:已迁移外壳族的基础规则(设置导航轨、搜索、导航项、
+开关旋钮、能力搜索、插件搜索、输入框壳、输入工具栏/芯片/占位符/输入,以及正文、代码
+卡(`code-block`、标题带、语言条)、Mermaid(块、主体、标题带、标题、错误、源码)、
+遮罩、工具输出、发送按钮与空状态标题)同样不得绘制字面量。
+
+豁免项逐条写明理由,不留隐形缺口:
+
+- `one-dark-pro` / `one-light` 的 Shiki 调色板(底板与其墨色由 Shiki 主题产出并以行内
+  颜色渲染,二者必须一起移动 —— 走 Shiki 主题而非 CSS 标记);
+- 半透明的黑/白 alpha 阴影值(`box-shadow`、`text-shadow`、`filter`),它们只叠加
+  暗度;不透明的阴影颜色仍会被检查;
+- ⌘K 的 `.search-overlay` 遮罩:它在两套调色板中都保持深色 45% 黑色混合,因而仍与
+  D148 的浅色更轻面纱不一致。这属于维护者决策,已登记为已知缺口;它确实会被检查,也
+  确实在该处被放行,删掉这条豁免 `pnpm lint` 就会失败。
+
+有两处边界是刻意留开的。族规则是固定清单而非全量选择器:它拦住已迁移表面的回归,但
+拦不住没人审过的选择器上新出现的字面量。而该守卫是静态文本分析,看不到两条读标记的
+规则之间的级联冲突(#339 的成因)。正因如此,`pnpm test:e2e:theme-surfaces` 的真实
+渲染检查依然必要:它把每个已迁移表面的构建后配色与改动前采样的值逐项比对,因此任何
+默认值不等于原字面量的标记都会在此失败。该探针钉住的浅色限定 `.tool-row-content` 规则
+必须保留:它以 (0,3,0) 压过 `.tool-row-content.is-error`、并与
+`.tool-block.is-plain .tool-row-content` 同分靠顺序取胜,删掉它会让错误输出被染色、并
+只在浅色下给纯文本工具块加底纹。
 
 | 场景 | 处理 |
 |---|---|
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index 88f8a33798..5186f50d0e 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -3091,16 +3091,22 @@ IPC 请求无法关闭。
   3. 打开聊天会话旁边的工作面板(审阅/文件/浏览器)。
   4. 将鼠标悬停在文件树行或 diff 标头上;聚焦浏览器 URL 字段。
   5. 打开 confirmation/provider 对话框并检查稀松布。
-  6. 在明暗主题中分别检查设置导航轨、搜索、选中项、开启状态旋钮、输入框壳及
-     插件/能力搜索。应用自定义表面变量,使用键盘聚焦两类搜索,再移除自定义主题。
+  6. 在明暗主题中分别检查设置导航轨、搜索、选中项、开启状态旋钮、输入框壳、插件/
+     能力搜索、代码卡标题带、Mermaid 画布、工具输出、输入占位符与禁用发送芯片,
+     以及对话框遮罩和权限蒙层。应用自定义表面变量,使用键盘聚焦两类搜索,再移除
+     自定义主题。
 - **预期**:
   - 工作面板主体读取为安静的 `#fafafa` 插页纸,带有白色标题带。
   - 设置字段、浏览器 URL、分段轨道和快捷键键帽使用浅色嵌入填充;聚焦场通过中性环提升。
   - 切换开启状态使白色旋钮保持在近乎黑色的轨道上。
   - 悬停可通过共享运动令牌轻松填充 file-tree/diff/resize。
   - 浅色对话稀松布比深色 45% 面纱(约 28% 墨水)更柔软。
-  - 自定义变量改变对应表面及搜索焦点填充;移除后恢复内置 8-bit RGBA 配色与
-    原有阴影/焦点环。本批不迁移正文或遮罩,不改插件主题 API。
+  - 工具输出保持既有级联:浅色在错误输出与纯文本工具块上都绘制同一层较浅底纹,深色
+    显示错误色调并让纯文本块保持透明。
+  - 自定义变量改变对应表面、键帽墨色及搜索焦点填充;移除后恢复内置 8-bit RGBA
+    配色与原有阴影/焦点环。正文墨色混合跟随 `--ds-text-primary`,
+    `one-dark-pro` / `one-light` 的 Shiki 底板与其墨色按设计归 Shiki 主题所有。
+    本批不改插件主题 API。
 - **链接规格**:`04-ux/07-ui-design-system.md`、`04-ux/08-component-spec.md`
 - **接受**:D148
 - **里程碑**:M5
diff --git a/scripts/e2e-theme-surfaces.mjs b/scripts/e2e-theme-surfaces.mjs
index b0a70dba70..052413a5e7 100644
--- a/scripts/e2e-theme-surfaces.mjs
+++ b/scripts/e2e-theme-surfaces.mjs
@@ -19,9 +19,11 @@ try {
   const css = [...appHtml.matchAll(/href="([^" ]+\.css)"/g)].map((match) => match[1]);
   assert(css.length, "Build the app with pnpm build:js before running this check");
   await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true });
-  await writeFile(join(temp, "index.html"), `Theme surface regression${css.map((path) => ``).join("")}
+  await writeFile(join(temp, "index.html"), `Theme surface regression${css.map((path) => ``).join("")}
     
-
Composer surface
+
Composer surface
Dialog scrim
Permission veil
+

Answer prose with a K keycap and an inline chip.

thinking code
Code card head band
Mermaid canvas
+
Tool output
Plain tool output
Error tool output
`); await writeFile( join(temp, "main.cjs"), diff --git a/scripts/e2e/theme-surfaces.js b/scripts/e2e/theme-surfaces.js index da28c45630..bacc0493fe 100644 --- a/scripts/e2e/theme-surfaces.js +++ b/scripts/e2e/theme-surfaces.js @@ -1,7 +1,19 @@ /** Real CSS cascade probe; fixture classes use the built app's complete stylesheet. */ // Built-in sRGB paint and shadows sampled from the pre-fix a9053531 app. -const DEFAULT_RGBA = {"light":{"rail":[244,244,244,255],"search":[255,255,255,255],"active":[224,224,225,255],"knob":[255,255,255,255],"composer":[255,255,255,255],"pluginSearch":[243,243,243,255],"capabilitySearch":[243,243,243,255],"pluginFocus":[255,255,255,255],"capabilityFocus":[255,255,255,255]},"dark":{"rail":[0,0,0,255],"search":[33,33,33,255],"active":[255,255,255,26],"knob":[24,24,24,255],"composer":[33,33,33,245],"pluginSearch":[255,255,255,13],"capabilitySearch":[255,255,255,13],"pluginFocus":[255,255,255,18],"capabilityFocus":[255,255,255,18]}}; +const DEFAULT_RGBA = {"light":{"rail":[244,244,244,255],"search":[255,255,255,255],"active":[224,224,225,255],"knob":[255,255,255,255],"composer":[255,255,255,255],"pluginSearch":[243,243,243,255],"capabilitySearch":[243,243,243,255],"pluginFocus":[255,255,255,255],"capabilityFocus":[255,255,255,255],"scrim":[25,29,32,71],"modalVeil":[25,28,31,82],"toolRow":[51,51,51,5],"toolRowPlain":[51,51,51,5],"toolRowError":[51,51,51,5],"codeHead":[28,28,28,9],"mermaidCanvas":[255,255,255,255],"thinkingCode":[240,240,240,255],"sendDisabled":[142,142,144,255],"kbd":[26,26,26,20]},"dark":{"rail":[0,0,0,255],"search":[33,33,33,255],"active":[255,255,255,26],"knob":[24,24,24,255],"composer":[33,33,33,245],"pluginSearch":[255,255,255,13],"capabilitySearch":[255,255,255,13],"pluginFocus":[255,255,255,18],"capabilityFocus":[255,255,255,18],"scrim":[0,0,0,115],"modalVeil":[24,24,24,199],"toolRow":[255,255,255,9],"toolRowPlain":[0,0,0,0],"toolRowError":[255,99,99,18],"codeHead":[255,255,255,10],"mermaidCanvas":[35,35,35,255],"thinkingCode":[255,255,255,11],"sendDisabled":[255,255,255,46],"kbd":[255,255,255,20]}}; const DEFAULT_SHADOWS = {"light":{"knob":"rgba(0, 0, 0, 0.22) 0px 1px 2px 0px","composer":"rgba(0, 0, 0, 0.04) 0px 3px 7.5px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px","pluginFocus":"oklab(0.22559 -0.00131416 -0.00642684 / 0.35) 0px 0px 0px 1px","capabilityFocus":"oklab(0.22559 -0.00131416 -0.00642684 / 0.35) 0px 0px 0px 1px"},"dark":{"knob":"rgba(0, 0, 0, 0.16) 0px 1px 2px 0px","composer":"rgba(0, 0, 0, 0.04) 0px 3px 7.5px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px","pluginFocus":"oklab(0.999994 0.0000455678 0.0000200868 / 0.45) 0px 0px 0px 1px","capabilityFocus":"oklab(0.999994 0.0000455678 0.0000200868 / 0.45) 0px 0px 0px 1px"}}; +/** + * Surface entries are `[selector, token]` for a surface the token fills, + * `[selector, token, "ink"]` when the token paints the element's text colour, or + * `[selector, null, "pinned"]` for a surface whose built-in paint is pinned but + * which is not token-driven: the tool-row cascade, where light keeps its own + * lighter tile over the plain and error variants. + * + * The batch migrated in issue #341 (prose ink, code-card chrome, scrims, tool + * output, disabled send chip) is listed here so a contributed theme has to move + * each one. The one-dark-pro / one-light Shiki plate is deliberately absent: it + * belongs to the Shiki theme, not to a CSS token. + */ const surfaces = { rail: [".settings-nav", "--ds-settings-rail-bg"], search: [".settings-search", "--ds-settings-field-bg"], @@ -10,6 +22,16 @@ const surfaces = { composer: [".composer-shell", "--ds-bg-elevated-primary"], pluginSearch: [".plugins-search", "--ds-field-inset-bg"], capabilitySearch: [".agent-capability-search-wrap", "--ds-field-inset-bg"], + scrim: [".overlay", "--ds-scrim"], + modalVeil: [".plugins-modal-backdrop", "--ds-modal-veil"], + toolRow: [".tool-row-content", "--ds-tool-row-bg"], + toolRowPlain: [".tool-block.is-plain .tool-row-content", null, "pinned"], + toolRowError: [".tool-row-content.is-error", null, "pinned"], + codeHead: [".code-block-head", "--ds-code-head-bg"], + mermaidCanvas: [".mermaid-block-body", "--ds-mermaid-canvas"], + thinkingCode: [".thinking-prose code", "--ds-thinking-code-bg"], + sendDisabled: [".send-btn:disabled", "--ds-send-disabled-bg"], + kbd: [".prose-chat kbd", "--ds-prose-kbd-fg", "ink"], }; const customColors = { "--ds-settings-rail-bg": "#243645", @@ -20,6 +42,14 @@ const customColors = { "--ds-bg-composer": "#715667", "--ds-field-inset-bg": "#325e53", "--ds-field-inset-focus-bg": "#487b65", + "--ds-scrim": "#2f4a5e", + "--ds-modal-veil": "#5e3b2f", + "--ds-tool-row-bg": "#3f5e2f", + "--ds-code-head-bg": "#5e2f4a", + "--ds-mermaid-canvas": "#2f5e58", + "--ds-thinking-code-bg": "#4a2f5e", + "--ds-send-disabled-bg": "#6b5c2f", + "--ds-prose-kbd-fg": "#a1b2c3", }; const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; @@ -39,7 +69,13 @@ async function settle() { function sample(selector) { const element = document.querySelector(selector); const style = getComputedStyle(element); - return { color: style.backgroundColor, rgba: rgba(style.backgroundColor), shadow: style.boxShadow }; + return { + color: style.backgroundColor, + rgba: rgba(style.backgroundColor), + shadow: style.boxShadow, + ink: style.color, + inkRgba: rgba(style.color), + }; } globalThis.themeSurfacesProbe = async (theme, custom) => { document.documentElement.dataset.theme = theme; @@ -66,9 +102,11 @@ globalThis.themeSurfacesProbe = async (theme, custom) => { await settle(); } if (custom) { - for (const [id, [, token]] of Object.entries(surfaces)) { + for (const [id, [, token, channel = "bg"]] of Object.entries(surfaces)) { + if (channel === "pinned") continue; const effectiveToken = id === "composer" && theme === "light" ? "--ds-bg-composer" : token; - if (String(values[id].rgba) !== String(rgba(customColors[effectiveToken]))) failures.push(`${id} ignores ${token}`); + const measured = channel === "ink" ? values[id].inkRgba : values[id].rgba; + if (String(measured) !== String(rgba(customColors[effectiveToken]))) failures.push(`${id} ignores ${token}`); } for (const id of ["pluginFocus", "capabilityFocus"]) { if (String(values[id].rgba) !== String(rgba(customColors["--ds-field-inset-focus-bg"]))) failures.push(`${id} ignores focus token`); diff --git a/scripts/style-surface-tokens.mjs b/scripts/style-surface-tokens.mjs index d9d38807f9..5bed88e7a2 100644 --- a/scripts/style-surface-tokens.mjs +++ b/scripts/style-surface-tokens.mjs @@ -1,20 +1,148 @@ -/** Scoped guard for the ordinary chrome fills migrated in issue #341. - * Prose, scrims, shadows, and other component families remain outside this batch. - * Runtime CSS checks cover cascade and focus behavior; this guard catches literals. +/** + * Surface-colour guard for the renderer stylesheets (issue #341, extends #339). + * + * Two rules: + * + * 1. THEME OVERRIDE RULE — inside a `:root[data-theme="…"]` rule every colour + * declaration in `COLOR_PROPERTIES` (plus `box-shadow`) must resolve through + * a custom property. A literal there both raises specificity above the base + * token rule and skips the variable, so the surface is pinned out of every + * contributed theme's reach (D419). Custom-property definitions are allowed + * when the rule *is* a theme root (`:root`, `:root[data-theme="…"]`): the + * token blocks are exactly where literal values belong. A custom property + * holding a literal colour on any other selector is a violation, because it + * shadows the root token for that subtree. + * Custom-property definitions are allowed when the rule *is* a theme root + * (`:root`, `:root[data-theme="…"]`): the token blocks are exactly where + * literal values belong. A custom property holding a literal colour on any + * other selector is a violation, because it shadows the root token for that + * subtree. + * + * 2. CHROME FAMILY RULE — the base rules of the chrome families migrated in + * #339/#341 (settings rail, settings search, settings nav item, switch + * thumb, agent-capability search, plugin search, composer shell, and the + * prose / code-card / Mermaid / scrim / tool-row / send-button / + * placeholder families tokenized in this batch) must not paint a literal + * colour either. This rule covers that fixed family list, not every + * selector in the renderer: it catches a regression in a migrated surface, + * not a brand-new literal on a selector nobody has reviewed yet. + * + * Exemptions are enumerated here with reasons instead of being invisible gaps: + * + * - `SHIKI_PALETTE` — the `one-dark-pro` / `one-light` syntax plate and its + * ink are authored by the Shiki theme the renderer selects + * (`apps/desktop/src/lib/shiki.ts`: `THEMES = { light: "one-light", + * dark: "one-dark-pro" }`) and are emitted as inline colours on the + * highlighted markup. A CSS token could only move the plate and leave the + * token colours behind, so the pair has to move together (through the Shiki + * theme), not through `--ds-*`. + * - `BOX_SHADOW_ALPHA` — a shadow only offsets darkness, so a black- or + * white-alpha shadow value is allowed. A background/scrim that is a black + * mix still paints a surface and is checked. + * - `ALLOWED_LITERALS` — base-rule fills the guard deliberately allows, each + * with the decision it answers to. + * + * Known boundary: this guard is static CSS text analysis. It cannot see a + * cascade conflict between two token rules (#339's root cause) or a plugin's + * own stylesheet; `pnpm test:e2e:theme-surfaces` covers the rendered cascade. */ -const SURFACE = /\.(?:settings-nav|settings-search|settings-nav-item|settings-toggle-thumb|agent-capability-search-wrap|plugins-search|composer-shell)(?![\w-])/; -const LITERAL = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(|\b(?:white|black)\b/i; +/** Selectors whose base rules must not paint a literal colour (rule 2). */ +const CHROME_FAMILY = + /\.(?:settings-nav|settings-search|settings-nav-item|settings-toggle-thumb|agent-capability-search-wrap|plugins-search|composer-shell|prose-chat|thinking-prose|code-block|code-block-head|code-block-lang|mermaid-block|mermaid-block-body|mermaid-block-head|mermaid-block-title|mermaid-block-error|mermaid-source|overlay|search-overlay|plugins-modal-backdrop|tool-row-content|send-btn|empty-hero|composer-placeholder|composer-input|mode-chip|composer-toolbar)(?![\w-])/; + +/** A selector that is a theme root: `:root` or `:root[data-theme="…"]`. */ +const THEME_ROOT = /^:root(?:\[data-theme=["'][^"']*["']\])?$/; + +/** Colour literals: hex, colour functions, and the `white` / `black` keywords. */ +const LITERAL = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(|\b(?:white|black)\b/i; + +/** Shadow colours that only offset darkness, so they carry no palette. */ +const BOX_SHADOW_ALPHA = + /#(?:0{6}|f{6})(?!f{2})[\da-f]{2}\b|\b(?:rgba|rgb)\(\s*0\s*[, ]\s*0\s*[, ]\s*0\s*[,/]\s*[\d.]+%?\s*\)|\b(?:rgba|rgb)\(\s*255\s*[, ]\s*255\s*[, ]\s*255\s*[,/]\s*[\d.]+%?\s*\)/gi; + +/** one-dark-pro / one-light plate and ink (see the file header). */ +const SHIKI_PALETTE = /#(?:282c34|abb2bf|383a42|fafafa|d7dae0)\b/gi; + +const KEYWORDS = /\b(?:transparent|currentcolor|inherit|initial|unset|revert|none)\b/gi; + +const COLOR_PROPERTIES = + /^(?:color|background|background-color|background-image|text-decoration-color|-webkit-text-fill-color|border|border-(?:top|right|bottom|left)|border-color|border-(?:top|right|bottom|left)-color|outline|outline-color|fill|stroke|text-shadow|filter|accent-color|caret-color)$/; + +/** + * Literals the guard deliberately allows, each with the reason it answers to. + * Keyed by the selector text the declaration appears in. + */ +export const ALLOWED_LITERALS = [ + { + selector: /\.search-overlay/, + property: "background", + reason: + "The ⌘K spotlight has never had a light override and keeps the dark 45% black veil in both palettes. §8.4/D148 give the light scrim ~28% #1a1c1f 'so white dialogs do not sit under a heavy veil', and .search-dialog is --ds-bg-elevated-opaque white, so tokenizing this value either changes built-in light paint or records a value the design system does not own. A maintainer call, recorded here instead of guessed (issue #341).", + }, +]; + +function replaceAll(value, patterns) { + let out = value; + for (const pattern of patterns) out = out.replace(pattern, " "); + return out; +} + +/** Drop everything that is not a bare colour literal, then test what remains. */ +function residualLiterals(value, { shadow }) { + let out = value; + let previous; + do { + previous = out; + out = out.replace(/var\([^()]*\)/g, " "); + } while (out !== previous); + out = replaceAll(out, [SHIKI_PALETTE, KEYWORDS]); + if (shadow) out = out.replace(BOX_SHADOW_ALPHA, " "); + // `color-mix(...)` is only a container: the colours it mixes have already + // been kept or stripped above. + return out; +} + +function allowed(selector, property) { + return ALLOWED_LITERALS.some( + (entry) => entry.selector.test(selector) && entry.property === property, + ); +} +/** + * Report literal surface colours in a stylesheet. + * @param {string} css + * @returns {Array<{line: number, property: string, value: string, reason: string}>} + */ export function findLiteralSurfaceColors(css) { // Retain newlines so diagnostics keep their source line numbers. const source = css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " ")); const violations = []; for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { - if (!SURFACE.test(rule[1])) continue; - for (const declaration of rule[2].matchAll(/\b(background(?:-color)?)\s*:\s*([^;{}]+)/g)) { - if (!LITERAL.test(declaration[2])) continue; + const selector = rule[1].trim(); + const isThemeOverride = /data-theme/.test(selector); + const isThemeRoot = isThemeOverride && selector.split(",").some((part) => THEME_ROOT.test(part.trim())); + const inChromeFamily = CHROME_FAMILY.test(selector); + if (!isThemeOverride && !inChromeFamily) continue; + for (const declaration of rule[2].matchAll(/([-\w]+)\s*:\s*([^;{}]+)/g)) { + const [, property, raw] = declaration; + const value = raw.trim(); + const isCustomProperty = property.startsWith("--"); + if (isCustomProperty && isThemeRoot) continue; // the token blocks own literals + const isShadow = property === "box-shadow" || property === "text-shadow" || property === "filter"; + const checkable = COLOR_PROPERTIES.test(property) || isShadow || isCustomProperty; + if (!checkable) continue; + const residual = residualLiterals(value, { shadow: isShadow }); + if (!LITERAL.test(residual)) continue; + if (allowed(selector, property)) continue; const offset = rule.index + rule[1].length + 1 + declaration.index; - violations.push({ line: source.slice(0, offset).split("\n").length, property: declaration[1], value: declaration[2].trim() }); + violations.push({ + line: source.slice(0, offset).split("\n").length, + property, + value: value.replace(/\s+/g, " "), + reason: isCustomProperty + ? "a component-local custom property holding a literal colour shadows the root token" + : "use a surface token", + }); } } return violations;