feat(themes): public tokens for diffs, git status, tabs, dividers, rows, links (#36578)

* feat(themes): public tokens for diffs, git status, tabs, dividers, rows, links

Adds 29 public app theme tokens (diff, git decorations, tab bar, split
divider, current workspace row, host badge, chat bubble, text link), each
falling back to today's built-in color or CSS chain so existing themes and
built-ins render the same. Monaco's diff editor now paints from the diff
tokens, with sharper default diff tints (from #34698) and a readable dark
ignored-file color. The contrast gate learns one-sided drops, translucent
surfaces checked over their base, and main.css chains for half-set pairs.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(themes): check omitted diff tints, chat bubbles and dark host badges as painted

The contrast gate now derives an omitted diff tint from the theme's hue (and
drops the hue when its tint hides code), checks chat bubbles against chat's
mixed foreground on the chat canvas, and models the dark host badge at 80%
accent. The main.css fallback table moves to plugin-theme-css-fallbacks.ts.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(themes): blame a diff hue only for tints it painted, and check chat diff rows

A diff tint the theme set itself no longer drops the theme's hue when it
fails, and chat diff rows (hue-mixed text on the line tint over the chat
canvas) are now checked too.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(themes): new theme tokens only expose colors; unthemed surfaces paint exactly as before

Every new token now falls back to the exact value its surface used before:
- Diff hues default to the git decoration colors at the previous per-side
  percentages, and chat diff text keeps its 70% mix.
- Monaco's diff view, overview ruler and links keep Monaco's stock colors
  unless the theme painted a diff or link token. The colors go through the
  derived Monaco theme instead of a global CSS override.
- --text-link has no :root default; each link rule keeps its old literal as
  the fallback, including the markdown-light/-dark preview split and the
  always-#58a6ff Monaco doc link.
- Tab bar, tab text and indicator fall back per element (bg-card,
  text-foreground, ...) instead of through :root aliases.
- Dark --git-decoration-ignored goes back to #6e6e6e (#35910 ships separately).

The contrast gate skips checks that have no theme token to drop, so themes
that set only older tokens pay no extra probes.

* perf(themes): contrast check skips ring checks the theme didn't set and precomputes drop lists

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-31 19:20:51 -07:00
committed by GitHub
co-authored by Claude
parent 82ac952ad7
commit daa11df406
26 changed files with 643 additions and 160 deletions
+68 -39
View File
@@ -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 |
+13 -1
View File
@@ -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.
+2
View File
@@ -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):
@@ -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
})
+58 -43
View File
@@ -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;
+2 -2
View File
@@ -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,
@@ -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 {
@@ -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"
>
<div
className="flex h-9 shrink-0 cursor-grab items-center border-b border-border bg-[var(--bg-titlebar,var(--card))] active:cursor-grabbing"
className="flex h-9 shrink-0 cursor-grab items-center border-b border-border bg-[var(--bg-titlebar,var(--tab-bar,var(--card)))] active:cursor-grabbing"
data-floating-terminal-shortcut-surface
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
@@ -50,8 +50,8 @@ function DiffRow({ line, gutterWidth }: { line: NativeChatEditLine; gutterWidth:
<div
className={cn(
'flex items-start',
line.kind === 'add' && 'bg-[var(--diff-added-ground)]',
line.kind === 'del' && 'bg-[var(--diff-removed-ground)]'
line.kind === 'add' && 'bg-[var(--diff-added-background)]',
line.kind === 'del' && 'bg-[var(--diff-removed-background)]'
)}
>
{gutterWidth > 0 ? (
@@ -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'
)}
@@ -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()
})
@@ -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'
]
@@ -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':
@@ -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(
@@ -270,7 +270,7 @@ export default function TabGroupPanel({
{/* Omit external strips entirely because hidden duplicates would still register competing drag sortables. */}
{tabStrip !== 'attached' ? null : (
<div
className="@container/tab-strip h-[32px] shrink-0 border-b border-border bg-card"
className="@container/tab-strip h-[32px] shrink-0 border-b border-border bg-tab-bar"
data-tab-group-strip-id={groupId}
data-terminal-focus-release-surface="true"
data-worktree-id={worktreeId}
@@ -22,7 +22,7 @@ export default function TabGroupSplitLayout({
ref={setDragRootNode}
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
>
<div className="h-[4px] shrink-0 bg-card" data-terminal-focus-release-surface="true" />
<div className="h-[4px] shrink-0 bg-tab-bar" data-terminal-focus-release-surface="true" />
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<TabGroupSplitNodeTree
layout={layout}
+1 -1
View File
@@ -20,7 +20,7 @@ const badgeVariants = cva(
link: 'text-primary underline-offset-4 [a&]:hover:underline',
/** Workspace execution-host context, quieter and squarer than status chips. */
hostContext:
'h-4 rounded border-border bg-accent px-1.5 text-[10px] leading-none text-muted-foreground dark:border-border/50 dark:bg-accent/80',
'h-4 rounded border-border bg-[var(--host-badge,var(--accent))] px-1.5 text-[10px] leading-none text-[var(--host-badge-foreground,var(--muted-foreground))] dark:border-border/50 dark:bg-[var(--host-badge,color-mix(in_oklab,var(--accent)_80%,transparent))]',
/** A skill or file sitting inline in the chat message box, ringed while selected. */
promptPill:
'max-w-full border-border bg-secondary px-1.5 py-0 align-baseline text-muted-foreground data-[selected=true]:ring-1 data-[selected=true]:ring-ring'
@@ -244,6 +244,67 @@ describe('applyPluginAppTheme', () => {
})
})
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 })
+9 -7
View File
@@ -157,7 +157,7 @@ function withFloatingSurfaces(tokens: Record<string, string>): Record<string, st
/** Paints tokens that stay readable over this mode's built-ins; returns what was painted. */
function paintPluginTokens(
tokens: Record<string, string>,
{ tokens, base }: Pick<CachedPluginAppTheme, 'tokens' | 'base'>,
options: PluginThemePaintOptions
): Record<string, string> {
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<string, string> {
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)
}
@@ -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
@@ -24,7 +24,12 @@ const dusk: Record<string, string> = {
}
function derive(tokens: Record<string, string>, 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' } },
@@ -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<Record<string, string>>
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,
+151 -31
View File
@@ -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<Record<string, string>>,
base: 'light' | 'dark',
builtIns: Readonly<Record<string, string>>,
probe: ColorProbe
): Record<string, string> {
@@ -71,11 +162,27 @@ export function safePluginThemeTokens(
}
}
const builtInColors = new Map<string, Rgba | null>()
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
) {
@@ -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<string>(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<string, DerivationRule> = {
const DERIVED_TOKENS: Record<string, PluginThemeDerivationRule> = {
'--editor-surface': ['--background'],
'--card': ['--background'],
'--card-foreground': ['--foreground'],
@@ -0,0 +1,56 @@
import type { PluginThemeDerivationRule } from './plugin-theme-artifact'
/** A derivation, or `tint`: `color-mix(in srgb, <tint> <percent>%, 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
}
@@ -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<string>(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<PluginAppThemeToken, string> = {
'--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<Record<PluginAppThemeToken, string>> = {
'--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(<name>)`, 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`