diff --git a/docs/reference/plugin-theme-tokens.md b/docs/reference/plugin-theme-tokens.md
index 6c0879b2dcc..4ea8fee46af 100644
--- a/docs/reference/plugin-theme-tokens.md
+++ b/docs/reference/plugin-theme-tokens.md
@@ -4,42 +4,71 @@
Every key an app theme may set in `tokens` (see [plugin themes](plugin-themes.md)). Any other key is refused. An omitted token falls back as listed; when that source is omitted too, it falls back the same way in turn.
-| Token | Required | When omitted |
-| --- | --- | --- |
-| `--background` | yes | |
-| `--editor-surface` | no | same as `--background` |
-| `--foreground` | yes | |
-| `--card` | no | same as `--background` |
-| `--card-foreground` | no | same as `--foreground` |
-| `--popover` | no | same as `--card` |
-| `--popover-foreground` | no | same as `--card-foreground` |
-| `--primary` | no | same as `--foreground` |
-| `--primary-foreground` | no | same as `--background` |
-| `--secondary` | no | same as `--muted` |
-| `--secondary-foreground` | no | same as `--foreground` |
-| `--muted` | no | same as `--secondary` |
-| `--muted-foreground` | no | `--foreground` mixed 70% into `--background` |
-| `--accent` | no | `--foreground` mixed 12% into `--background` |
-| `--accent-foreground` | no | same as `--foreground` |
-| `--border` | no | `--foreground` mixed 16% into `--background` |
-| `--input` | no | same as `--border` |
-| `--ring` | no | same as `--primary` |
-| `--chart-1` | no | same as `--primary` |
-| `--chart-2` | no | Orca's built-in color |
-| `--chart-3` | no | Orca's built-in color |
-| `--chart-4` | no | Orca's built-in color |
-| `--chart-5` | no | Orca's built-in color |
-| `--sidebar` | no | `--foreground` mixed 8% into `--background` |
-| `--sidebar-foreground` | no | same as `--foreground` |
-| `--sidebar-primary` | no | same as `--primary` |
-| `--sidebar-primary-foreground` | no | same as `--primary-foreground` |
-| `--sidebar-accent` | no | same as `--accent` |
-| `--sidebar-accent-foreground` | no | same as `--accent-foreground` |
-| `--sidebar-border` | no | same as `--border` |
-| `--sidebar-ring` | no | same as `--ring` |
-| `--worktree-sidebar` | no | same as `--sidebar` |
-| `--worktree-sidebar-foreground` | no | same as `--sidebar-foreground` |
-| `--worktree-sidebar-accent` | no | same as `--sidebar-accent` |
-| `--worktree-sidebar-accent-foreground` | no | same as `--sidebar-accent-foreground` |
-| `--worktree-sidebar-border` | no | same as `--sidebar-border` |
-| `--worktree-sidebar-ring` | no | same as `--sidebar-ring` |
+| Token | Paints | Required | When omitted |
+| --- | --- | --- | --- |
+| `--background` | App canvas | yes | |
+| `--editor-surface` | Code editor and notebook background | no | same as `--background` |
+| `--foreground` | Body text | yes | |
+| `--card` | Cards and panels | no | same as `--background` |
+| `--card-foreground` | Text on `--card` | no | same as `--foreground` |
+| `--popover` | Menus, popovers and dialogs | no | same as `--card` |
+| `--popover-foreground` | Text on `--popover` | no | same as `--card-foreground` |
+| `--primary` | Primary buttons | no | same as `--foreground` |
+| `--primary-foreground` | Text on `--primary` | no | same as `--background` |
+| `--secondary` | Secondary buttons | no | same as `--muted` |
+| `--secondary-foreground` | Text on `--secondary` | no | same as `--foreground` |
+| `--muted` | Quiet fills | no | same as `--secondary` |
+| `--muted-foreground` | Secondary text | no | `--foreground` mixed 70% into `--background` |
+| `--accent` | Hover and selected fills | no | `--foreground` mixed 12% into `--background` |
+| `--accent-foreground` | Text on `--accent` | no | same as `--foreground` |
+| `--border` | Borders and separators | no | `--foreground` mixed 16% into `--background` |
+| `--input` | Input borders | no | same as `--border` |
+| `--ring` | Focus rings | no | same as `--primary` |
+| `--chart-1` | Chart series 1 | no | same as `--primary` |
+| `--chart-2` | Chart series 2 | no | Orca's built-in color |
+| `--chart-3` | Chart series 3 | no | Orca's built-in color |
+| `--chart-4` | Chart series 4 | no | Orca's built-in color |
+| `--chart-5` | Chart series 5 | no | Orca's built-in color |
+| `--sidebar` | Right sidebar and settings navigation | no | `--foreground` mixed 8% into `--background` |
+| `--sidebar-foreground` | Text on `--sidebar` | no | same as `--foreground` |
+| `--sidebar-primary` | Primary controls in sidebars | no | same as `--primary` |
+| `--sidebar-primary-foreground` | Text on `--sidebar-primary` | no | same as `--primary-foreground` |
+| `--sidebar-accent` | Hover and selected rows in sidebars | no | same as `--accent` |
+| `--sidebar-accent-foreground` | Text on `--sidebar-accent` | no | same as `--accent-foreground` |
+| `--sidebar-border` | Sidebar borders | no | same as `--border` |
+| `--sidebar-ring` | Focus rings in sidebars | no | same as `--ring` |
+| `--worktree-sidebar` | Left workspace sidebar | no | same as `--sidebar` |
+| `--worktree-sidebar-foreground` | Text on `--worktree-sidebar` | no | same as `--sidebar-foreground` |
+| `--worktree-sidebar-accent` | Hover and multi-selected rows in the workspace sidebar | no | same as `--sidebar-accent` |
+| `--worktree-sidebar-accent-foreground` | Text on `--worktree-sidebar-accent` | no | same as `--sidebar-accent-foreground` |
+| `--worktree-sidebar-border` | Workspace sidebar borders | no | same as `--sidebar-border` |
+| `--worktree-sidebar-ring` | Focus rings in the workspace sidebar | no | same as `--sidebar-ring` |
+| `--worktree-sidebar-active` | The current workspace row; may be translucent, it paints over `--worktree-sidebar` | no | a translucent wash of `--worktree-sidebar-foreground` |
+| `--worktree-sidebar-active-border` | Border of the current workspace row | no | a mix of `--worktree-sidebar-border` |
+| `--host-badge` | Execution-host badge on workspaces (for example `Local`, an SSH host) | no | light: same as `--accent`; dark: `--accent` at 80% opacity |
+| `--host-badge-foreground` | Text on `--host-badge` | no | same as `--muted-foreground` |
+| `--tab-bar` | Strip behind tabs, including the title bar when tabs sit in it | no | same as `--card` |
+| `--tab-active` | Active tab | no | light: same as `--tab-bar`; dark: `--foreground` mixed 6% into `--tab-bar` |
+| `--tab-active-foreground` | Active tab text, and inactive tab text on hover | no | same as `--foreground` |
+| `--tab-active-indicator` | Line under the active tab | no | same as `--tab-active-foreground` |
+| `--tab-inactive` | Inactive tabs | no | light: `--foreground` mixed 4% into `--tab-bar`; dark: same as `--tab-bar` |
+| `--tab-inactive-foreground` | Inactive tab text | no | same as `--muted-foreground` |
+| `--split-divider` | Divider between split tab groups | no | Orca's built-in color |
+| `--split-divider-hover` | Split divider while hovered or dragged | no | Orca's built-in color |
+| `--chat-user-message` | Your messages in chat; may be translucent, it paints over the chat | no | `--foreground` mixed lightly into the chat background |
+| `--text-link` | Links in Markdown previews and editors | no | Orca's built-in color |
+| `--diff-added` | Added-line hue: Monaco overview ruler and chat diff text | no | same as `--git-decoration-added` |
+| `--diff-added-background` | Added lines in diffs | no | light: `--diff-added` at 13% opacity; dark: `--diff-added` at 16% opacity |
+| `--diff-added-emphasis` | Added characters within a changed line | no | light: `--diff-added` at 15% opacity; dark: `--diff-added` at 20% opacity |
+| `--diff-added-gutter` | Gutter beside added lines | no | light: `--diff-added` at 26% opacity; dark: `--diff-added` at 30% opacity |
+| `--diff-removed` | Removed-line hue: Monaco overview ruler and chat diff text | no | same as `--git-decoration-deleted` |
+| `--diff-removed-background` | Removed lines in diffs | no | light: `--diff-removed` at 11% opacity; dark: `--diff-removed` at 18% opacity |
+| `--diff-removed-emphasis` | Removed characters within a changed line | no | light: `--diff-removed` at 15% opacity; dark: `--diff-removed` at 20% opacity |
+| `--diff-removed-gutter` | Gutter beside removed lines | no | light: `--diff-removed` at 22% opacity; dark: `--diff-removed` at 32% opacity |
+| `--git-decoration-added` | Added files in the file explorer and source control | no | Orca's built-in color |
+| `--git-decoration-modified` | Modified files | no | Orca's built-in color |
+| `--git-decoration-deleted` | Deleted files | no | Orca's built-in color |
+| `--git-decoration-renamed` | Renamed files | no | Orca's built-in color |
+| `--git-decoration-untracked` | Untracked files | no | Orca's built-in color |
+| `--git-decoration-copied` | Copied files | no | Orca's built-in color |
+| `--git-decoration-ignored` | Git-ignored files | no | Orca's built-in color |
diff --git a/docs/reference/plugin-themes.md b/docs/reference/plugin-themes.md
index 674d116cc62..be063515cfc 100644
--- a/docs/reference/plugin-themes.md
+++ b/docs/reference/plugin-themes.md
@@ -30,7 +30,19 @@ Each entry is `{ "id", "title", "path" }` pointing at a JSON file of at most 16
refused.
- Each text/surface pair (for example `--background`/`--foreground`,
`--primary`/`--primary-foreground`) must be opaque with at least 4.5:1 contrast; a pair that
- fails is dropped and keeps Orca's built-in colors.
+ fails is dropped and keeps Orca's built-in colors. A pair you set only half of is checked
+ against what the other half falls back to. Some checks drop only your token: `--text-link`
+ needs 4.5:1 on `--editor-surface`, each `--git-decoration-*` 3:1 on `--sidebar`, and
+ `--worktree-sidebar-active`, `--chat-user-message` and the diff line and character tints
+ 4.5:1 with the text drawn on them. Those surfaces and `--host-badge` may be translucent; they
+ are checked as painted over the surface beneath.
+- An omitted surface token paints exactly what Orca paints without a theme. The code editor's
+ diff view and links keep Monaco's own colors unless the theme sets a diff token or
+ `--text-link`. Setting only `--diff-added` and `--diff-removed` is enough; the line, gutter and
+ changed-character tints follow them.
+- When the user picks **Match terminal** or **Tinted** for the left sidebar, it ignores the
+ theme's current-row colors (and, for Match terminal, its host badge colors); a chat set to
+ match the terminal ignores the theme's chat and diff colors.
- Trust and approval dialogs always use Orca's own colors, whatever theme is active.
- `pnpm validate:plugin` refuses unknown keys, tokens and slots in every theme file. Orca itself
skips them, so a theme that sets a token added in a newer release still loads on older ones.
diff --git a/docs/styleguide/color.md b/docs/styleguide/color.md
index c17772ac611..4c07f74b1d2 100644
--- a/docs/styleguide/color.md
+++ b/docs/styleguide/color.md
@@ -32,6 +32,8 @@ For diff status, file-tree decorations, and the changes view, use the `--git-dec
Use these _only_ for git status. Don't reuse them for unrelated state colors — that breaks the convention.
+Diff hunks (line, changed-character and gutter tints in chat diffs and Monaco's diff editor) use `--diff-added` / `--diff-removed` and their `-background`, `-emphasis` and `-gutter` tints instead. The hues default to the matching decoration colors, so themes can tune diffs separately from file status. Diff _text_ in chat uses `text-chat-diff-added` / `text-chat-diff-removed`.
+
## List rows: hover, selected, current
A common point of drift. Use these conventions for any list-style row (worktrees, command palette items, settings nav):
diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts
index 6de32527cd2..e399a59b880 100644
--- a/src/renderer/src/app-shell/use-document-appearance.ts
+++ b/src/renderer/src/app-shell/use-document-appearance.ts
@@ -52,7 +52,7 @@ export function useDocumentAppearance(): void {
// Why wait for the list: until it loads, the startup cache is still the right paint.
// Why theme too: contrast is checked against the built-ins of the current mode.
if (pluginThemesLoaded && theme) {
- applyPluginAppTheme(pluginTheme)
+ const painted = applyPluginAppTheme(pluginTheme)
// Why read the document: the editor should match what was painted, built-ins included.
const computed = getComputedStyle(document.documentElement)
usePluginMonacoThemeStore.setState({
@@ -60,6 +60,7 @@ export function useDocumentAppearance(): void {
? derivePluginMonacoTheme({
base: pluginTheme.base,
read: (token) => computed.getPropertyValue(token),
+ painted,
probe: browserColorProbe,
tokenColors: pluginTheme.tokenColors
})
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 78fac511192..7de4a8c10bc 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -147,10 +147,16 @@
--color-workspace-status-done: var(--workspace-status-done);
--color-workspace-status-review: var(--workspace-status-review);
--color-workspace-status-progress: var(--workspace-status-progress);
- --color-tab-group-split-divider: var(--tab-group-split-divider);
- --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong);
--color-tab-active: var(--tab-active);
--color-tab-inactive: var(--tab-inactive);
+ /* Public tokens a theme may omit; resolved per element so unthemed tabs paint exactly as before. */
+ --color-tab-bar: var(--tab-bar, var(--card));
+ --color-tab-active-foreground: var(--tab-active-foreground, var(--foreground));
+ --color-tab-active-indicator: var(
+ --tab-active-indicator,
+ var(--tab-active-foreground, var(--foreground))
+ );
+ --color-tab-inactive-foreground: var(--tab-inactive-foreground, var(--muted-foreground));
--color-git-graph-ref: var(--git-graph-ref);
--color-git-graph-remote-ref: var(--git-graph-remote-ref);
--color-git-graph-base-ref: var(--git-graph-base-ref);
@@ -295,11 +301,12 @@
--markdown-search-match: #facc15;
--markdown-search-match-active: #fb923c;
/* Split dividers must clear 3:1 against --card even before hover; --border is too faint. */
- --tab-group-split-divider: #868690;
- --tab-group-split-divider-strong: #71717a;
+ --split-divider: #868690;
+ --split-divider-hover: #71717a;
+ /* `--tab-bar` is set only by themes; keep in sync with PLUGIN_APP_THEME_CSS_FALLBACKS. */
/* White --card can't be lifted, so light mode recesses inactive tabs to keep the active one lighter. */
- --tab-active: var(--card);
- --tab-inactive: color-mix(in srgb, var(--foreground) 4%, var(--card));
+ --tab-active: var(--tab-bar, var(--card));
+ --tab-inactive: color-mix(in srgb, var(--foreground) 4%, var(--tab-bar, var(--card)));
}
.dark {
@@ -412,10 +419,10 @@
--markdown-search-match: #facc15;
--markdown-search-match-active: #fb923c;
/* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
- --tab-group-split-divider: #71717a;
- --tab-group-split-divider-strong: #a1a1aa;
- --tab-active: color-mix(in srgb, var(--foreground) 6%, var(--card));
- --tab-inactive: var(--card);
+ --split-divider: #71717a;
+ --split-divider-hover: #a1a1aa;
+ --tab-active: color-mix(in srgb, var(--foreground) 6%, var(--tab-bar, var(--card)));
+ --tab-inactive: var(--tab-bar, var(--card));
}
/* Status and diff colors follow the chat scheme without copying the app palette. */
@@ -447,10 +454,14 @@
--git-decoration-untracked: #007100;
--git-decoration-copied: #007acc;
--git-decoration-ignored: #8c8c8c;
- --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 13%, transparent);
- --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 26%, transparent);
- --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 11%, transparent);
- --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 22%, transparent);
+ --diff-added: var(--git-decoration-added);
+ --diff-removed: var(--git-decoration-deleted);
+ --diff-added-background: color-mix(in srgb, var(--diff-added) 13%, transparent);
+ --diff-added-gutter: color-mix(in srgb, var(--diff-added) 26%, transparent);
+ --diff-added-emphasis: color-mix(in srgb, var(--diff-added) 15%, transparent);
+ --diff-removed-background: color-mix(in srgb, var(--diff-removed) 11%, transparent);
+ --diff-removed-gutter: color-mix(in srgb, var(--diff-removed) 22%, transparent);
+ --diff-removed-emphasis: color-mix(in srgb, var(--diff-removed) 15%, transparent);
}
.dark,
@@ -478,10 +489,14 @@
--git-decoration-untracked: #73c991;
--git-decoration-copied: #73c991;
--git-decoration-ignored: #6e6e6e;
- --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 16%, transparent);
- --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 30%, transparent);
- --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 18%, transparent);
- --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 32%, transparent);
+ --diff-added: var(--git-decoration-added);
+ --diff-removed: var(--git-decoration-deleted);
+ --diff-added-background: color-mix(in srgb, var(--diff-added) 16%, transparent);
+ --diff-added-gutter: color-mix(in srgb, var(--diff-added) 30%, transparent);
+ --diff-added-emphasis: color-mix(in srgb, var(--diff-added) 20%, transparent);
+ --diff-removed-background: color-mix(in srgb, var(--diff-removed) 18%, transparent);
+ --diff-removed-gutter: color-mix(in srgb, var(--diff-removed) 32%, transparent);
+ --diff-removed-emphasis: color-mix(in srgb, var(--diff-removed) 20%, transparent);
}
.plugin-security-chrome {
@@ -632,10 +647,9 @@ body[data-modal-overlay-open]
var(--chat-source-muted-foreground) var(--chat-faint-mix),
var(--chat-canvas)
);
- --chat-user-surface: color-mix(
- in srgb,
- var(--chat-source-foreground) var(--chat-user-mix),
- var(--chat-canvas)
+ --chat-user-surface: var(
+ --chat-user-message,
+ color-mix(in srgb, var(--chat-source-foreground) var(--chat-user-mix), var(--chat-canvas))
);
--chat-user-border: color-mix(
in srgb,
@@ -674,16 +688,8 @@ body[data-modal-overlay-open]
--chat-warning: color-mix(in srgb, var(--status-warning) 70%, var(--chat-source-foreground));
/* What the reader has not seen yet: the transcript's "New" marker, in the note hue. */
--chat-unread: color-mix(in srgb, var(--callout-note) 70%, var(--chat-source-foreground));
- --chat-diff-added: color-mix(
- in srgb,
- var(--git-decoration-added) 70%,
- var(--chat-source-foreground)
- );
- --chat-diff-removed: color-mix(
- in srgb,
- var(--git-decoration-deleted) 70%,
- var(--chat-source-foreground)
- );
+ --chat-diff-added: color-mix(in srgb, var(--diff-added) 70%, var(--chat-source-foreground));
+ --chat-diff-removed: color-mix(in srgb, var(--diff-removed) 70%, var(--chat-source-foreground));
}
@layer base {
@@ -955,7 +961,7 @@ html.native-shell .app-layout {
content: '';
position: absolute;
border-radius: 1px;
- background: var(--tab-group-split-divider);
+ background: var(--split-divider);
transition: background 100ms ease;
}
@@ -977,7 +983,7 @@ html.native-shell .app-layout {
.tab-group-split-resize-handle:hover::after,
.tab-group-split-resize-handle.is-dragging::after {
- background: var(--tab-group-split-divider-strong);
+ background: var(--split-divider-hover);
}
/* Electron drag hit-testing ignores z-index; every overlapping Radix portal needs explicit no-drag. */
@@ -1001,7 +1007,8 @@ html.native-shell .app-layout {
display: flex;
flex-direction: row;
align-items: center;
- background: var(--bg-titlebar, var(--card));
+ /* Tabs can sit in the title bar, so it paints the tab strip color. */
+ background: var(--bg-titlebar, var(--tab-bar, var(--card)));
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
user-select: none;
@@ -1425,18 +1432,26 @@ html.native-shell .app-layout {
/* Why: the brighter border carries the selected state; a translucent wash keeps
card text legible instead of lifting the surface toward the foreground color. */
[data-worktree-card-surface][data-worktree-card-active='primary'] {
- border-color: color-mix(in srgb, var(--worktree-sidebar-border) 40%, transparent);
- background: color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, transparent);
+ border-color: var(
+ --worktree-sidebar-active-border,
+ color-mix(in srgb, var(--worktree-sidebar-border) 40%, transparent)
+ );
+ background: var(
+ --worktree-sidebar-active,
+ color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, transparent)
+ );
box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 4%, transparent);
}
.dark [data-worktree-card-surface][data-worktree-card-active='primary'] {
- border-color: color-mix(
- in srgb,
- var(--worktree-sidebar-foreground) 18%,
- var(--worktree-sidebar-border)
+ border-color: var(
+ --worktree-sidebar-active-border,
+ color-mix(in srgb, var(--worktree-sidebar-foreground) 18%, var(--worktree-sidebar-border))
+ );
+ background: var(
+ --worktree-sidebar-active,
+ color-mix(in srgb, var(--worktree-sidebar-foreground) 10%, transparent)
);
- background: color-mix(in srgb, var(--worktree-sidebar-foreground) 10%, transparent);
box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 3%, transparent);
}
@@ -2426,7 +2441,7 @@ html.native-shell .app-layout {
}
.monaco-editor .monaco-markdown-doc-link {
- color: #58a6ff !important;
+ color: var(--text-link, #58a6ff) !important;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css
index 3809a9ce091..c469ce93e0f 100644
--- a/src/renderer/src/assets/markdown-preview.css
+++ b/src/renderer/src/assets/markdown-preview.css
@@ -601,11 +601,11 @@
}
.markdown-light .markdown-body a {
- color: #0969da;
+ color: var(--text-link, #0969da);
}
.markdown-dark .markdown-body a {
- color: #58a6ff;
+ color: var(--text-link, #58a6ff);
}
.markdown-light .markdown-body .markdown-doc-link-broken,
diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css
index c955563829a..89c2d23295d 100644
--- a/src/renderer/src/assets/rich-markdown-editor.css
+++ b/src/renderer/src/assets/rich-markdown-editor.css
@@ -1128,7 +1128,7 @@
.rich-markdown-editor a,
.rich-markdown-editor .ProseMirror a {
- color: #0969da;
+ color: var(--text-link, #0969da);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
@@ -1141,7 +1141,7 @@
}
.dark .rich-markdown-editor a {
- color: #58a6ff;
+ color: var(--text-link, #58a6ff);
}
.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
@@ -1149,14 +1149,14 @@
}
.rich-markdown-editor .rich-markdown-html-superscript-link {
- color: #0969da;
+ color: var(--text-link, #0969da);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
- color: #58a6ff;
+ color: var(--text-link, #58a6ff);
}
.rich-markdown-mod-held
@@ -1447,14 +1447,14 @@
}
.rich-markdown-editor .rich-markdown-doc-link {
- color: #0969da;
+ color: var(--text-link, #0969da);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link {
- color: #58a6ff;
+ color: var(--text-link, #58a6ff);
}
.rich-markdown-editor .rich-markdown-doc-link.rich-markdown-doc-link--missing {
@@ -1474,14 +1474,14 @@
/* Match committed doc-link styling while [[target]] is still being typed to avoid a visual jump. */
.rich-markdown-editor .rich-markdown-doc-link-preview {
- color: #0969da;
+ color: var(--text-link, #0969da);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link-preview {
- color: #58a6ff;
+ color: var(--text-link, #58a6ff);
}
.rich-markdown-editor .rich-markdown-doc-link-preview.rich-markdown-doc-link-preview--missing {
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx
index 0b7563f55f5..546faef899b 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx
@@ -114,7 +114,7 @@ export function renderFloatingTerminalPanelSurface({
className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14"
>
{gutterWidth > 0 ? (
diff --git a/src/renderer/src/components/native-chat/NativeChatDiffView.tsx b/src/renderer/src/components/native-chat/NativeChatDiffView.tsx
index 18db20eeef9..2783a44401f 100644
--- a/src/renderer/src/components/native-chat/NativeChatDiffView.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatDiffView.tsx
@@ -12,8 +12,8 @@ export function NativeChatDiffView({ lines }: { lines: DiffLine[] }): React.JSX.
key={i}
className={cn(
'whitespace-pre-wrap break-words px-2',
- line.kind === 'add' && 'bg-[var(--diff-added-ground)] text-chat-diff-added',
- line.kind === 'del' && 'bg-[var(--diff-removed-ground)] text-chat-diff-removed',
+ line.kind === 'add' && 'bg-[var(--diff-added-background)] text-chat-diff-added',
+ line.kind === 'del' && 'bg-[var(--diff-removed-background)] text-chat-diff-removed',
line.kind === 'meta' && 'text-chat-foreground-faint',
line.kind === 'context' && 'text-chat-foreground'
)}
diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
index 0da77188a11..3ce0083c670 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
@@ -104,8 +104,12 @@ describe('NativeChatToolRun', () => {
)
openToolRunMembers()
- expect(screen.getByText('after').closest('div')).toHaveClass('bg-[var(--diff-added-ground)]')
- expect(screen.getByText('before').closest('div')).toHaveClass('bg-[var(--diff-removed-ground)]')
+ expect(screen.getByText('after').closest('div')).toHaveClass(
+ 'bg-[var(--diff-added-background)]'
+ )
+ expect(screen.getByText('before').closest('div')).toHaveClass(
+ 'bg-[var(--diff-removed-background)]'
+ )
expect(container).not.toHaveTextContent('"changes"')
expect(container.querySelector('pre')).toBeNull()
})
diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts
index 5314972bf94..051df56a86d 100644
--- a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts
@@ -56,9 +56,9 @@ const statusRoles = [
'--status-warning',
'--git-decoration-added',
'--git-decoration-deleted',
- '--diff-added-ground',
+ '--diff-added-background',
'--diff-added-gutter',
- '--diff-removed-ground',
+ '--diff-removed-background',
'--diff-removed-gutter'
]
diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts
index 07d8da15616..a57fe85f40b 100644
--- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts
+++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts
@@ -139,6 +139,8 @@ export function nativeChatAppearanceStyle(
'--secondary': 'var(--muted)',
'--secondary-foreground': 'var(--chat-foreground-strong)',
'--ring': 'color-mix(in srgb, var(--chat-source-foreground) 44%, var(--chat-canvas))',
+ // Why: the terminal palette owns a matching chat, so a plugin theme's bubble yields too.
+ '--chat-user-message': 'initial',
'--chat-source-background': background,
'--chat-source-foreground': foreground,
'--chat-source-muted-foreground':
diff --git a/src/renderer/src/components/tab-bar/drop-indicator.ts b/src/renderer/src/components/tab-bar/drop-indicator.ts
index a02ebca9ed6..bad68cefa45 100644
--- a/src/renderer/src/components/tab-bar/drop-indicator.ts
+++ b/src/renderer/src/components/tab-bar/drop-indicator.ts
@@ -13,12 +13,12 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
// Keep the foreground marker above selection/unread layers; zero horizontal inset avoids scrollWidth jitter at the strip’s end.
export const ACTIVE_TAB_INDICATOR_CLASSES =
- 'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-foreground z-20'
+ 'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-tab-active-indicator z-20'
export function getTabRootStateClasses(isActive: boolean): string {
return isActive
- ? 'bg-tab-active text-foreground'
- : 'bg-tab-inactive text-muted-foreground hover:text-foreground'
+ ? 'bg-tab-active text-tab-active-foreground'
+ : 'bg-tab-inactive text-tab-inactive-foreground hover:text-tab-active-foreground'
}
export function getTabStripBorderClasses(
diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
index 95df5973c66..96a42abc21c 100644
--- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx
+++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
@@ -270,7 +270,7 @@ export default function TabGroupPanel({
{/* Omit external strips entirely because hidden duplicates would still register competing drag sortables. */}
{tabStrip !== 'attached' ? null : (
-
+
{
})
})
+ it('drops only the status or link color that fails, never the shared surface', () => {
+ const sidebar = { '--sidebar': '#202020', '--sidebar-foreground': '#ffffff' }
+ expect(
+ painted({
+ ...sidebar,
+ '--git-decoration-ignored': '#303030',
+ '--git-decoration-added': '#4ec95a'
+ })
+ ).toEqual({ ...sidebar, '--git-decoration-added': '#4ec95a' })
+ expect(painted({ '--editor-surface': '#151515', '--text-link': '#1a1a40' })).toEqual({
+ '--editor-surface': '#151515'
+ })
+ })
+
+ it('checks a translucent row as painted over the sidebar it tints', () => {
+ const sidebar = { '--worktree-sidebar': '#202020', '--worktree-sidebar-foreground': '#f0f0f0' }
+ expect(painted({ ...sidebar, '--worktree-sidebar-active': '#ffffff1a' })).toEqual({
+ ...sidebar,
+ '--worktree-sidebar-active': '#ffffff1a'
+ })
+ expect(painted({ ...sidebar, '--worktree-sidebar-active': '#ffffffd0' })).toEqual(sidebar)
+ })
+
+ it('checks half of a pair against what main.css paints for the other half', () => {
+ // The badge text falls back to --muted-foreground, which this badge would hide.
+ expect(painted({ '--accent': '#303030', '--host-badge': '#a1a1a1' })).toEqual({
+ '--accent': '#303030'
+ })
+ // The inactive tab follows --card, so its text is checked on the theme's card.
+ expect(painted({ '--card': '#202020', '--tab-inactive-foreground': '#262626' })).toEqual({
+ '--card': '#202020'
+ })
+ })
+
+ it('checks omitted surfaces as main.css paints them in this mode', () => {
+ // A bright hue makes its 20% added-line tint unreadable, so the hue itself is dropped.
+ const dim = {
+ '--background': '#333333',
+ '--editor-surface': '#333333',
+ '--foreground': '#cccccc'
+ }
+ expect(painted({ ...dim, '--diff-added': '#ffffff' })).toEqual(dim)
+ // A tint the theme set itself takes the blame alone.
+ expect(
+ painted({ ...dim, '--diff-added': '#3ddc84', '--diff-added-background': '#ffffff' })
+ ).toEqual({ ...dim, '--diff-added': '#3ddc84' })
+ // Bubble text is chat's mixed foreground, not raw --foreground.
+ const black = { '--background': '#000000', '--foreground': '#ffffff' }
+ expect(painted({ ...black, '--chat-user-message': '#727272' })).toEqual(black)
+ // Dark badges paint --accent at 80%.
+ const sidebar = { '--worktree-sidebar': '#000000', '--worktree-sidebar-foreground': '#ffffff' }
+ expect(
+ painted({
+ ...sidebar,
+ '--accent': '#ffffff',
+ '--accent-foreground': '#000000',
+ '--host-badge-foreground': '#767676'
+ })
+ ).toEqual({ ...sidebar, '--accent': '#ffffff', '--accent-foreground': '#000000' })
+ })
+
it('keeps the startup cache tied to the theme id and revision until cleared', () => {
const storage = memoryStorage()
applyPluginAppTheme(theme({ '--background': '#101018' }), { ...options(), storage })
diff --git a/src/renderer/src/lib/document-theme.ts b/src/renderer/src/lib/document-theme.ts
index de666c15bde..6bea8c91f8b 100644
--- a/src/renderer/src/lib/document-theme.ts
+++ b/src/renderer/src/lib/document-theme.ts
@@ -157,7 +157,7 @@ function withFloatingSurfaces(tokens: Record): Record,
+ { tokens, base }: Pick,
options: PluginThemePaintOptions
): Record {
const root = options.root ?? document.documentElement
@@ -165,6 +165,7 @@ function paintPluginTokens(
setPluginTokens(root, {})
const safe = safePluginThemeTokens(
tokens,
+ base,
(options.builtIns ?? documentBuiltIns)(),
options.probe ?? browserColorProbe
)
@@ -184,20 +185,21 @@ function writeCache(storage: Storage | null, cached: CachedPluginAppTheme | null
}
}
-/** Overlays a plugin theme's safe tokens; tokens it no longer sets fall back to main.css. Call
- * after the document's light/dark mode matches the theme's base. */
+/** Overlays a plugin theme's safe tokens and returns them; tokens it no longer sets fall back to
+ * main.css. Call after the document's light/dark mode matches the theme's base. */
export function applyPluginAppTheme(
theme: PluginThemeRegistration | null,
options: PluginThemePaintOptions & { storage?: Storage | null } = {}
-): void {
+): Record {
const storage = options.storage === undefined ? globalThis.localStorage : options.storage
if (!theme) {
setPluginTokens(options.root ?? document.documentElement, {})
writeCache(storage, null)
- return
+ return {}
}
- const tokens = paintPluginTokens(theme.tokens, options)
+ const tokens = paintPluginTokens(theme, options)
writeCache(storage, { id: theme.id, revision: theme.revision, base: theme.base, tokens })
+ return tokens
}
/** The last applied plugin theme, for painting before settings and plugins load. */
@@ -238,5 +240,5 @@ export function applyCachedPluginAppTheme(
cached: CachedPluginAppTheme,
options: PluginThemePaintOptions = {}
): void {
- paintPluginTokens(cached.tokens, options)
+ paintPluginTokens(cached, options)
}
diff --git a/src/renderer/src/lib/left-sidebar-appearance.ts b/src/renderer/src/lib/left-sidebar-appearance.ts
index c8a424718df..dda6c5c92b4 100644
--- a/src/renderer/src/lib/left-sidebar-appearance.ts
+++ b/src/renderer/src/lib/left-sidebar-appearance.ts
@@ -70,9 +70,15 @@ function buildSurfaceVariables(args: {
'--sidebar-accent': accent,
'--sidebar-accent-foreground': foreground,
'--sidebar-border': border,
- '--sidebar-ring': ring
+ '--sidebar-ring': ring,
+ // Why `initial`: the chosen appearance owns this subtree, so a plugin theme's row colors yield
+ // to each consumer's built-in fallback derived from the tokens above.
+ '--worktree-sidebar-active': 'initial',
+ '--worktree-sidebar-active-border': 'initial'
}
if (overrideTextTokens) {
+ vars['--host-badge'] = 'initial'
+ vars['--host-badge-foreground'] = 'initial'
vars['--background'] = background
vars['--foreground'] = foreground
vars.color = foreground
diff --git a/src/renderer/src/lib/plugin-monaco-theme.test.ts b/src/renderer/src/lib/plugin-monaco-theme.test.ts
index a0b3af7dc30..26934560d55 100644
--- a/src/renderer/src/lib/plugin-monaco-theme.test.ts
+++ b/src/renderer/src/lib/plugin-monaco-theme.test.ts
@@ -24,7 +24,12 @@ const dusk: Record = {
}
function derive(tokens: Record, base: 'light' | 'dark' = 'dark') {
- return derivePluginMonacoTheme({ base, read: (token) => tokens[token] ?? '', probe })
+ return derivePluginMonacoTheme({
+ base,
+ read: (token) => tokens[token] ?? '',
+ painted: tokens,
+ probe
+ })
}
describe('derivePluginMonacoTheme', () => {
@@ -55,10 +60,30 @@ describe('derivePluginMonacoTheme', () => {
expect(derive({ ...dusk, '--editor-surface': 'not-a-color', '--background': '' })).toBeNull()
})
+ it('keeps stock diff and link colors unless the theme painted those tokens', () => {
+ const document = { ...dusk, '--diff-added': '#00ff00', '--text-link': '#58a6ff' }
+ const read = (token: string) => document[token] ?? ''
+ const stock = derivePluginMonacoTheme({ base: 'dark', read, painted: dusk, probe })
+ expect(
+ Object.keys(stock?.data.colors ?? {}).filter((key) => /diff|textLink/.test(key))
+ ).toEqual([])
+ const themed = derivePluginMonacoTheme({
+ base: 'dark',
+ read,
+ painted: { ...dusk, '--diff-added': '#00ff00' },
+ probe
+ })
+ expect(themed?.data.colors).toMatchObject({
+ 'diffEditorOverview.insertedForeground': '#00ff00'
+ })
+ expect(themed?.data.colors).not.toHaveProperty('textLink.foreground')
+ })
+
it("paints the theme's tokenColors, blending translucent ones and dropping unreadable ones", () => {
const theme = derivePluginMonacoTheme({
base: 'dark',
read: (token) => dusk[token] ?? '',
+ painted: dusk,
probe,
tokenColors: [
{ scope: 'keyword', settings: { foreground: '#ff2bd6' } },
diff --git a/src/renderer/src/lib/plugin-monaco-theme.ts b/src/renderer/src/lib/plugin-monaco-theme.ts
index b0649eedf48..dc29b85bf15 100644
--- a/src/renderer/src/lib/plugin-monaco-theme.ts
+++ b/src/renderer/src/lib/plugin-monaco-theme.ts
@@ -15,6 +15,22 @@ const BUILT_IN_SYNTAX: Record<'light' | 'dark', readonly string[]> = {
dark: ['#608b4e', '#ce9178', '#569cd6', '#3dc9b0', '#b5cea8']
}
+// [Monaco color, token, hue the token follows when omitted]. Why only when the theme painted one:
+// themes that set none keep Monaco's stock diff and link colors, exactly as before these tokens.
+const MONACO_OPT_IN_COLORS: readonly (readonly [string, string, string])[] = [
+ ...(['inserted', 'removed'] as const).flatMap((kind) => {
+ const hue = kind === 'inserted' ? '--diff-added' : '--diff-removed'
+ return [
+ [`diffEditor.${kind}LineBackground`, `${hue}-background`, hue],
+ [`diffEditor.${kind}TextBackground`, `${hue}-emphasis`, hue],
+ [`diffEditorGutter.${kind}LineBackground`, `${hue}-gutter`, hue],
+ [`diffEditorOverview.${kind}Foreground`, hue, hue]
+ ] as const
+ }),
+ ['textLink.foreground', '--text-link', '--text-link'],
+ ['textLink.activeForeground', '--text-link', '--text-link']
+]
+
function hex([r, g, b, a]: Rgba): string {
const byte = (value: number): string =>
Math.round(Math.min(255, Math.max(0, value)))
@@ -41,6 +57,8 @@ export function derivePluginMonacoTheme(input: {
base: 'light' | 'dark'
/** A document CSS variable's computed value. */
read: (token: string) => string
+ /** The tokens the theme painted, after the contrast check. */
+ painted: Readonly>
probe: ColorProbe
tokenColors?: readonly PluginThemeTokenColorRule[]
}): PluginMonacoTheme | null {
@@ -88,6 +106,12 @@ export function derivePluginMonacoTheme(input: {
if (border) {
colors['editorWidget.border'] = hex(border)
}
+ for (const [key, token, hue] of MONACO_OPT_IN_COLORS) {
+ const value = input.painted[token] || input.painted[hue] ? color(token) : null
+ if (value) {
+ colors[key] = hex(value)
+ }
+ }
const data: Monaco.editor.IStandaloneThemeData = {
base: input.base === 'dark' ? 'vs-dark' : 'vs',
inherit: true,
diff --git a/src/renderer/src/lib/plugin-theme-tokens.ts b/src/renderer/src/lib/plugin-theme-tokens.ts
index c02aafbe1dd..6c8f2426fee 100644
--- a/src/renderer/src/lib/plugin-theme-tokens.ts
+++ b/src/renderer/src/lib/plugin-theme-tokens.ts
@@ -1,36 +1,119 @@
+import {
+ pluginAppThemeCssFallback,
+ type PluginThemeCssFallbackRule
+} from '../../../shared/plugins/artifacts/plugin-theme-css-fallbacks'
+
/** sRGB channels 0-255 and alpha 0-1, or null when the value is not a CSS color. */
export type ColorProbe = (value: string) => readonly [number, number, number, number] | null
export type Rgba = readonly [number, number, number, number]
-// Why: a theme must not be able to hide text, so every text/surface pair keeps WCAG AA contrast.
-const CONTRAST_PAIRS: readonly (readonly [string, string])[] = [
- ['--background', '--foreground'],
- ['--background', '--muted-foreground'],
- // Notebook markdown renders directly on the editor surface.
- ['--editor-surface', '--foreground'],
- ['--editor-surface', '--muted-foreground'],
- ['--card', '--card-foreground'],
- ['--popover', '--popover-foreground'],
- ['--primary', '--primary-foreground'],
- ['--secondary', '--secondary-foreground'],
- ['--muted', '--muted-foreground'],
- ['--accent', '--accent-foreground'],
- ['--sidebar', '--sidebar-foreground'],
- ['--sidebar-primary', '--sidebar-primary-foreground'],
- ['--sidebar-accent', '--sidebar-accent-foreground'],
- ['--worktree-sidebar', '--worktree-sidebar-foreground'],
- ['--worktree-sidebar-accent', '--worktree-sidebar-accent-foreground']
-]
+type ContrastRule = {
+ surface: string
+ text: string
+ min: number
+ /** What a failure drops, when not both sides: never a shared token this check must not cost. */
+ drop?: readonly string[]
+ /** A translucent surface is checked as painted over this opaque one. */
+ over?: string
+ /** Also dropped when the theme omitted the surface, which then follows this token. */
+ surfaceSource?: string
+}
+
const MIN_TEXT_CONTRAST = 4.5
+// Why 3:1: status hues tint file names, and Orca's own light ignored color is 3.3:1.
+const MIN_STATUS_TEXT_CONTRAST = 3
+const textPair = (surface: string, text: string): ContrastRule => ({
+ surface,
+ text,
+ min: MIN_TEXT_CONTRAST
+})
+const GIT_DECORATIONS = [
+ 'added',
+ 'modified',
+ 'deleted',
+ 'renamed',
+ 'untracked',
+ 'copied',
+ 'ignored'
+]
+
+// Why: a theme must not be able to hide text, so every text/surface pair keeps WCAG AA contrast.
+const CONTRAST_RULE_DEFINITIONS: readonly ContrastRule[] = [
+ textPair('--background', '--foreground'),
+ textPair('--background', '--muted-foreground'),
+ // Notebook markdown renders directly on the editor surface.
+ textPair('--editor-surface', '--foreground'),
+ textPair('--editor-surface', '--muted-foreground'),
+ textPair('--card', '--card-foreground'),
+ textPair('--popover', '--popover-foreground'),
+ textPair('--primary', '--primary-foreground'),
+ textPair('--secondary', '--secondary-foreground'),
+ textPair('--muted', '--muted-foreground'),
+ textPair('--accent', '--accent-foreground'),
+ textPair('--sidebar', '--sidebar-foreground'),
+ textPair('--sidebar-primary', '--sidebar-primary-foreground'),
+ textPair('--sidebar-accent', '--sidebar-accent-foreground'),
+ textPair('--worktree-sidebar', '--worktree-sidebar-foreground'),
+ textPair('--worktree-sidebar-accent', '--worktree-sidebar-accent-foreground'),
+ {
+ ...textPair('--worktree-sidebar-active', '--worktree-sidebar-foreground'),
+ drop: ['--worktree-sidebar-active'],
+ over: '--worktree-sidebar'
+ },
+ { ...textPair('--host-badge', '--host-badge-foreground'), over: '--worktree-sidebar' },
+ textPair('--tab-active', '--tab-active-foreground'),
+ textPair('--tab-inactive', '--tab-inactive-foreground'),
+ // Hovered inactive tabs take the active text color.
+ textPair('--tab-inactive', '--tab-active-foreground'),
+ {
+ ...textPair('--chat-user-message', '--chat-foreground-strong'),
+ drop: ['--chat-user-message'],
+ over: '--chat-canvas'
+ },
+ ...['added', 'removed'].flatMap((side): ContrastRule[] => {
+ const tint = (kind: string): ContrastRule => ({
+ ...textPair(`--diff-${side}-${kind}`, '--foreground'),
+ drop: [`--diff-${side}-${kind}`],
+ surfaceSource: `--diff-${side}`,
+ over: '--editor-surface'
+ })
+ // Chat diff rows paint the hue-mixed text on the line tint over the chat canvas.
+ return [
+ tint('background'),
+ tint('emphasis'),
+ { ...tint('background'), text: `--chat-diff-${side}`, over: '--chat-canvas' }
+ ]
+ }),
+ { ...textPair('--editor-surface', '--text-link'), drop: ['--text-link'] },
+ ...GIT_DECORATIONS.map((status): ContrastRule => ({
+ surface: '--sidebar',
+ text: `--git-decoration-${status}`,
+ min: MIN_STATUS_TEXT_CONTRAST,
+ drop: [`--git-decoration-${status}`]
+ }))
+]
+const CONTRAST_RULES = CONTRAST_RULE_DEFINITIONS.map((rule) => ({
+ ...rule,
+ dropped: rule.drop ?? [rule.surface, rule.text]
+}))
// Why 2:1, not WCAG's 3:1: Orca's own light ring is 2.6:1; the goal is a ring that never vanishes.
const RINGS: readonly (readonly [string, string])[] = [
['--ring', '--background'],
['--sidebar-ring', '--sidebar'],
- ['--worktree-sidebar-ring', '--worktree-sidebar']
+ ['--worktree-sidebar-ring', '--worktree-sidebar'],
+ ['--tab-active-indicator', '--tab-active']
]
const MIN_RING_CONTRAST = 2
// Why a floor, not opaque: Orca's own dark borders are 7% white; 0 or near-0 alpha hides them.
-const BORDERS = ['--border', '--input', '--sidebar-border', '--worktree-sidebar-border']
+const BORDERS = [
+ '--border',
+ '--input',
+ '--sidebar-border',
+ '--worktree-sidebar-border',
+ '--worktree-sidebar-active-border',
+ '--split-divider',
+ '--split-divider-hover'
+]
const MIN_BORDER_ALPHA = 0.05
function luminance([r, g, b]: Rgba): number {
@@ -52,14 +135,22 @@ function over([r, g, b, a]: Rgba, [br, bg, bb]: Rgba): Rgba {
return [r * a + br * (1 - a), g * a + bg * (1 - a), b * a + bb * (1 - a), 1]
}
+/** `color-mix(in srgb, a percent%, b)`. */
+function mix(a: Rgba, b: Rgba, percent: number): Rgba {
+ const p = percent / 100
+ const channel = (index: 0 | 1 | 2 | 3): number => a[index] * p + b[index] * (1 - p)
+ return [channel(0), channel(1), channel(2), channel(3)]
+}
+
/**
* The plugin tokens that are safe to paint over the built-in ones for the theme's base.
- * Checks run on the effective set (plugin values, else built-in), and dropping a token can
- * expose a built-in value that breaks another pair, so checks repeat until nothing changes.
- * Dropped tokens keep their built-in values.
+ * Checks run on the effective set (plugin values, else what main.css derives from them, else
+ * built-in), and dropping a token can expose a value that breaks another pair, so checks repeat
+ * until nothing changes. Dropped tokens keep their built-in values.
*/
export function safePluginThemeTokens(
tokens: Readonly>,
+ base: 'light' | 'dark',
builtIns: Readonly>,
probe: ColorProbe
): Record {
@@ -71,11 +162,27 @@ export function safePluginThemeTokens(
}
}
const builtInColors = new Map()
- const effective = (token: string): Rgba | null => {
+ const derived = (rule: PluginThemeCssFallbackRule): Rgba | null => {
+ if ('tint' in rule) {
+ const hue = effective(rule.tint)
+ return hue ? [hue[0], hue[1], hue[2], (hue[3] * rule.percent) / 100] : null
+ }
+ if (!('mixForeground' in rule)) {
+ return rule.map(effective).find(Boolean) ?? null
+ }
+ const foreground = effective('--foreground')
+ const into = effective(rule.into ?? '--background')
+ return foreground && into ? mix(foreground, into, rule.mixForeground) : null
+ }
+ function effective(token: string): Rgba | null {
const own = accepted.get(token)
if (own) {
return own
}
+ const fallback = pluginAppThemeCssFallback(token, base)
+ if (fallback) {
+ return derived(fallback)
+ }
if (!builtInColors.has(token)) {
builtInColors.set(token, builtIns[token] ? probe(builtIns[token]) : null)
}
@@ -86,24 +193,37 @@ export function safePluginThemeTokens(
for (let changed = true; changed;) {
changed = false
- for (const [surface, text] of CONTRAST_PAIRS) {
- const surfaceColor = effective(surface)
- const textColor = effective(text)
+ for (const rule of CONTRAST_RULES) {
+ const dropped =
+ rule.surfaceSource && tokens[rule.surface] === undefined
+ ? [...rule.dropped, rule.surfaceSource]
+ : rule.dropped
+ // Why skip: a check with nothing of the theme's to drop can't change the result, so a
+ // theme that sets none of a check's tokens pays no probes for it.
+ if (!dropped.some((token) => accepted.has(token))) {
+ continue
+ }
+ const textColor = effective(rule.text)
+ const ownSurface = effective(rule.surface)
+ const beneath = rule.over ? effective(rule.over) : null
+ const surfaceColor = ownSurface && beneath?.[3] === 1 ? over(ownSurface, beneath) : ownSurface
if (
surfaceColor &&
textColor &&
(surfaceColor[3] < 1 ||
textColor[3] < 1 ||
- contrastRatio(surfaceColor, textColor) < MIN_TEXT_CONTRAST)
+ contrastRatio(surfaceColor, textColor) < rule.min)
) {
- changed = drop(surface, text) || changed
+ changed = drop(...dropped) || changed
}
}
for (const [ring, surface] of RINGS) {
const ringColor = accepted.get(ring)
+ if (!ringColor) {
+ continue
+ }
const surfaceColor = effective(surface)
if (
- ringColor &&
surfaceColor &&
contrastRatio(over(ringColor, surfaceColor), surfaceColor) < MIN_RING_CONTRAST
) {
diff --git a/src/shared/plugins/artifacts/plugin-theme-artifact.ts b/src/shared/plugins/artifacts/plugin-theme-artifact.ts
index 8fda312773e..1a70891f799 100644
--- a/src/shared/plugins/artifacts/plugin-theme-artifact.ts
+++ b/src/shared/plugins/artifacts/plugin-theme-artifact.ts
@@ -40,7 +40,36 @@ export const PLUGIN_APP_THEME_TOKENS = [
'--worktree-sidebar-accent',
'--worktree-sidebar-accent-foreground',
'--worktree-sidebar-border',
- '--worktree-sidebar-ring'
+ '--worktree-sidebar-ring',
+ '--worktree-sidebar-active',
+ '--worktree-sidebar-active-border',
+ '--host-badge',
+ '--host-badge-foreground',
+ '--tab-bar',
+ '--tab-active',
+ '--tab-active-foreground',
+ '--tab-active-indicator',
+ '--tab-inactive',
+ '--tab-inactive-foreground',
+ '--split-divider',
+ '--split-divider-hover',
+ '--chat-user-message',
+ '--text-link',
+ '--diff-added',
+ '--diff-added-background',
+ '--diff-added-emphasis',
+ '--diff-added-gutter',
+ '--diff-removed',
+ '--diff-removed-background',
+ '--diff-removed-emphasis',
+ '--diff-removed-gutter',
+ '--git-decoration-added',
+ '--git-decoration-modified',
+ '--git-decoration-deleted',
+ '--git-decoration-renamed',
+ '--git-decoration-untracked',
+ '--git-decoration-copied',
+ '--git-decoration-ignored'
] as const
const THEME_TOKEN_SET = new Set(PLUGIN_APP_THEME_TOKENS)
@@ -50,11 +79,11 @@ export const PLUGIN_APP_THEME_REQUIRED_TOKENS = ['--background', '--foreground']
// Why: an omitted token would otherwise keep the built-in value and clash with the theme's
// palette (e.g. a neutral grey selected row on a purple theme). A rule names source tokens
-// (first present wins) or mixes the theme's foreground into its background; the renderer's
-// contrast check still drops any derived pair that ends up unreadable.
-type DerivationRule = readonly string[] | { mixForeground: number }
+// (first present wins) or mixes the theme's foreground into its background (or `into`); the
+// renderer's contrast check still drops any derived pair that ends up unreadable.
+export type PluginThemeDerivationRule = readonly string[] | { mixForeground: number; into?: string }
-const DERIVED_TOKENS: Record = {
+const DERIVED_TOKENS: Record = {
'--editor-surface': ['--background'],
'--card': ['--background'],
'--card-foreground': ['--foreground'],
diff --git a/src/shared/plugins/artifacts/plugin-theme-css-fallbacks.ts b/src/shared/plugins/artifacts/plugin-theme-css-fallbacks.ts
new file mode 100644
index 00000000000..646dc6a7cee
--- /dev/null
+++ b/src/shared/plugins/artifacts/plugin-theme-css-fallbacks.ts
@@ -0,0 +1,56 @@
+import type { PluginThemeDerivationRule } from './plugin-theme-artifact'
+
+/** A derivation, or `tint`: `color-mix(in srgb, %, transparent)`. */
+export type PluginThemeCssFallbackRule =
+ | PluginThemeDerivationRule
+ | { tint: string; percent: number }
+
+const tint = (token: string, light: number, dark: number) => ({
+ light: { tint: token, percent: light },
+ dark: { tint: token, percent: dark }
+})
+
+/**
+ * What main.css paints for a token the theme omits (or the contrast check drops), derived from
+ * whatever the theme did paint. Never painted; the contrast check and the token reference read
+ * it. Internal chat colors appear so the chat bubble is checked against its real text. Keep in
+ * sync with main.css.
+ */
+const PLUGIN_APP_THEME_CSS_FALLBACKS: Record<
+ string,
+ PluginThemeCssFallbackRule | Record<'light' | 'dark', PluginThemeCssFallbackRule>
+> = {
+ '--tab-bar': ['--card'],
+ // Why per base: a white light --card can't be lifted, so light recesses the inactive tabs.
+ '--tab-active': { light: ['--tab-bar'], dark: { mixForeground: 6, into: '--tab-bar' } },
+ '--tab-active-foreground': ['--foreground'],
+ '--tab-active-indicator': ['--tab-active-foreground'],
+ '--tab-inactive': { light: { mixForeground: 4, into: '--tab-bar' }, dark: ['--tab-bar'] },
+ '--tab-inactive-foreground': ['--muted-foreground'],
+ '--host-badge': { light: ['--accent'], dark: { tint: '--accent', percent: 80 } },
+ '--host-badge-foreground': ['--muted-foreground'],
+ '--diff-added': ['--git-decoration-added'],
+ '--diff-removed': ['--git-decoration-deleted'],
+ '--diff-added-background': tint('--diff-added', 13, 16),
+ '--diff-added-emphasis': tint('--diff-added', 15, 20),
+ '--diff-added-gutter': tint('--diff-added', 26, 30),
+ '--diff-removed-background': tint('--diff-removed', 11, 18),
+ '--diff-removed-emphasis': tint('--diff-removed', 15, 20),
+ '--diff-removed-gutter': tint('--diff-removed', 22, 32),
+ '--chat-diff-added': { mixForeground: 30, into: '--diff-added' },
+ '--chat-diff-removed': { mixForeground: 30, into: '--diff-removed' },
+ // At the default chat contrast setting.
+ '--chat-canvas': { light: ['--background'], dark: { mixForeground: 5 } },
+ '--chat-foreground-strong': {
+ light: { mixForeground: 92, into: '--chat-canvas' },
+ dark: { mixForeground: 90, into: '--chat-canvas' }
+ }
+}
+
+export function pluginAppThemeCssFallback(
+ token: string,
+ base: 'light' | 'dark'
+): PluginThemeCssFallbackRule | undefined {
+ const rule = PLUGIN_APP_THEME_CSS_FALLBACKS[token]
+ return rule && 'light' in rule ? rule[base] : rule
+}
diff --git a/src/shared/plugins/artifacts/plugin-theme-token-reference-doc.ts b/src/shared/plugins/artifacts/plugin-theme-token-reference-doc.ts
index 8a2d0330dc2..2dd08c41fd8 100644
--- a/src/shared/plugins/artifacts/plugin-theme-token-reference-doc.ts
+++ b/src/shared/plugins/artifacts/plugin-theme-token-reference-doc.ts
@@ -3,21 +3,110 @@ import {
PLUGIN_APP_THEME_REQUIRED_TOKENS,
PLUGIN_APP_THEME_TOKENS
} from './plugin-theme-artifact'
+import { pluginAppThemeCssFallback } from './plugin-theme-css-fallbacks'
const REQUIRED = new Set(PLUGIN_APP_THEME_REQUIRED_TOKENS)
+type PluginAppThemeToken = (typeof PLUGIN_APP_THEME_TOKENS)[number]
+
+// Why a full record: a public token added without saying what it paints fails typecheck.
+const TOKEN_ROLES: Record = {
+ '--background': 'App canvas',
+ '--editor-surface': 'Code editor and notebook background',
+ '--foreground': 'Body text',
+ '--card': 'Cards and panels',
+ '--card-foreground': 'Text on `--card`',
+ '--popover': 'Menus, popovers and dialogs',
+ '--popover-foreground': 'Text on `--popover`',
+ '--primary': 'Primary buttons',
+ '--primary-foreground': 'Text on `--primary`',
+ '--secondary': 'Secondary buttons',
+ '--secondary-foreground': 'Text on `--secondary`',
+ '--muted': 'Quiet fills',
+ '--muted-foreground': 'Secondary text',
+ '--accent': 'Hover and selected fills',
+ '--accent-foreground': 'Text on `--accent`',
+ '--border': 'Borders and separators',
+ '--input': 'Input borders',
+ '--ring': 'Focus rings',
+ '--chart-1': 'Chart series 1',
+ '--chart-2': 'Chart series 2',
+ '--chart-3': 'Chart series 3',
+ '--chart-4': 'Chart series 4',
+ '--chart-5': 'Chart series 5',
+ '--sidebar': 'Right sidebar and settings navigation',
+ '--sidebar-foreground': 'Text on `--sidebar`',
+ '--sidebar-primary': 'Primary controls in sidebars',
+ '--sidebar-primary-foreground': 'Text on `--sidebar-primary`',
+ '--sidebar-accent': 'Hover and selected rows in sidebars',
+ '--sidebar-accent-foreground': 'Text on `--sidebar-accent`',
+ '--sidebar-border': 'Sidebar borders',
+ '--sidebar-ring': 'Focus rings in sidebars',
+ '--worktree-sidebar': 'Left workspace sidebar',
+ '--worktree-sidebar-foreground': 'Text on `--worktree-sidebar`',
+ '--worktree-sidebar-accent': 'Hover and multi-selected rows in the workspace sidebar',
+ '--worktree-sidebar-accent-foreground': 'Text on `--worktree-sidebar-accent`',
+ '--worktree-sidebar-border': 'Workspace sidebar borders',
+ '--worktree-sidebar-ring': 'Focus rings in the workspace sidebar',
+ '--worktree-sidebar-active':
+ 'The current workspace row; may be translucent, it paints over `--worktree-sidebar`',
+ '--worktree-sidebar-active-border': 'Border of the current workspace row',
+ '--host-badge': 'Execution-host badge on workspaces (for example `Local`, an SSH host)',
+ '--host-badge-foreground': 'Text on `--host-badge`',
+ '--tab-bar': 'Strip behind tabs, including the title bar when tabs sit in it',
+ '--tab-active': 'Active tab',
+ '--tab-active-foreground': 'Active tab text, and inactive tab text on hover',
+ '--tab-active-indicator': 'Line under the active tab',
+ '--tab-inactive': 'Inactive tabs',
+ '--tab-inactive-foreground': 'Inactive tab text',
+ '--split-divider': 'Divider between split tab groups',
+ '--split-divider-hover': 'Split divider while hovered or dragged',
+ '--chat-user-message': 'Your messages in chat; may be translucent, it paints over the chat',
+ '--text-link': 'Links in Markdown previews and editors',
+ '--diff-added': 'Added-line hue: Monaco overview ruler and chat diff text',
+ '--diff-added-background': 'Added lines in diffs',
+ '--diff-added-emphasis': 'Added characters within a changed line',
+ '--diff-added-gutter': 'Gutter beside added lines',
+ '--diff-removed': 'Removed-line hue: Monaco overview ruler and chat diff text',
+ '--diff-removed-background': 'Removed lines in diffs',
+ '--diff-removed-emphasis': 'Removed characters within a changed line',
+ '--diff-removed-gutter': 'Gutter beside removed lines',
+ '--git-decoration-added': 'Added files in the file explorer and source control',
+ '--git-decoration-modified': 'Modified files',
+ '--git-decoration-deleted': 'Deleted files',
+ '--git-decoration-renamed': 'Renamed files',
+ '--git-decoration-untracked': 'Untracked files',
+ '--git-decoration-copied': 'Copied files',
+ '--git-decoration-ignored': 'Git-ignored files'
+}
+
+// Why: these mix per mode in main.css, which the derivation rules cannot express.
+const MODE_MIXES: Partial> = {
+ '--worktree-sidebar-active': 'a translucent wash of `--worktree-sidebar-foreground`',
+ '--worktree-sidebar-active-border': 'a mix of `--worktree-sidebar-border`',
+ '--chat-user-message': '`--foreground` mixed lightly into the chat background'
+}
+
/** What an omitted token becomes, read back from the real derivation with every other token
* standing in as `var()`, so the table cannot drift from what Orca applies. */
-function omittedValue(token: string): string {
+function omittedValueFor(token: PluginAppThemeToken, base: 'light' | 'dark'): string {
const others = Object.fromEntries(
PLUGIN_APP_THEME_TOKENS.filter((other) => other !== token).map((other) => [
other,
`var(${other})`
])
)
- const derived = completePluginAppThemeTokens(others)[token]
+ const cssFallback = pluginAppThemeCssFallback(token, base)
+ if (cssFallback && 'tint' in cssFallback) {
+ return `\`${cssFallback.tint}\` at ${cssFallback.percent}% opacity`
+ }
+ const derived = cssFallback
+ ? 'mixForeground' in cssFallback
+ ? `color-mix(in srgb, var(--foreground) ${cssFallback.mixForeground}%, var(${cssFallback.into ?? '--background'}))`
+ : `var(${cssFallback[0]})`
+ : completePluginAppThemeTokens(others)[token]
if (derived === undefined) {
- return "Orca's built-in color"
+ return MODE_MIXES[token] ?? "Orca's built-in color"
}
const mix = /^color-mix\(in srgb, var\((--[\w-]+)\) (\d+)%, var\((--[\w-]+)\)\)$/.exec(derived)
if (mix) {
@@ -26,6 +115,12 @@ function omittedValue(token: string): string {
return derived.replace(/^var\((--[\w-]+)\)$/, 'same as `$1`')
}
+function omittedValue(token: PluginAppThemeToken): string {
+ const light = omittedValueFor(token, 'light')
+ const dark = omittedValueFor(token, 'dark')
+ return light === dark ? light : `light: ${light}; dark: ${dark}`
+}
+
/** Colour token list for theme authors, rendered from the public token set. */
export function renderPluginThemeTokenReference(): string {
const lines = [
@@ -35,11 +130,11 @@ export function renderPluginThemeTokenReference(): string {
'',
'Every key an app theme may set in `tokens` (see [plugin themes](plugin-themes.md)). Any other key is refused. An omitted token falls back as listed; when that source is omitted too, it falls back the same way in turn.',
'',
- '| Token | Required | When omitted |',
- '| --- | --- | --- |',
+ '| Token | Paints | Required | When omitted |',
+ '| --- | --- | --- | --- |',
...PLUGIN_APP_THEME_TOKENS.map(
(token) =>
- `| \`${token}\` | ${REQUIRED.has(token) ? 'yes' : 'no'} | ${REQUIRED.has(token) ? '' : omittedValue(token)} |`
+ `| \`${token}\` | ${TOKEN_ROLES[token]} | ${REQUIRED.has(token) ? 'yes' : 'no'} | ${REQUIRED.has(token) ? '' : omittedValue(token)} |`
)
]
return `${lines.join('\n')}\n`