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 e0c9a5c1f3..d014fdf5fe 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -479,19 +479,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 72e05134da..fa066a364a 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -4827,7 +4827,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**:
@@ -4836,9 +4838,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 f9b0b10879..ede514899e 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;