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`