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 surfacePermission veil
+ Answer prose with a K keycap and an inline chip.
thinking codeCode card head bandMermaid canvas
+ Tool outputPlain tool outputError 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;