See
docs/specs/glossary.mdfor Pane / Door vocabulary used in the surface hierarchy below. Defers toDESIGN.md: the named color rules (Bg-Only Chrome, Host-Theme-Only, Inset-Over-Border) and the Don'ts they carry. This spec owns the token plumbing under them.
VS Code supplies --vscode-*; standalone, website, and Pocket use applyTheme()
with bundled or installed themes — Pocket before first paint, including auth
(pocket-app.md owns its
browser-chrome sync). Every shipping host runs the same consumed-token
resolver (lib/src/lib/themes/vscode-color-resolver.ts) before rendering.
pnpm dev:lib is the one exception: lib/src/main.tsx installs the resolver
only for a real webview and nothing on that path applies a theme, so the dev
server renders with no --vscode-* at all.
Build every surface from the three list pairs — the chrome, and full standalone/Pocket screens like the auth flow, draw hierarchy from these foreground/background pairs and nothing else (rationale):
app-bg/app-fg— the page.header-active-bg/header-active-fg— the accent: a focused header, a titlebar band, the single primary action.header-inactive-bg/header-inactive-fg— a secondary surface: list rows, unfocused headers.
Hierarchy is the background swap between pairs; secondary text is alpha on
the same pair's own foreground (text-app-fg/70), never a separate token.
DESIGN.md's Do/Don't lists own the rest — bg-only chrome, no pass-through
--mt-* layer or one-off tokens, header hover — with the text-alarm-vs-*
ringing tint and destructive-action error styling as the semantic exceptions.
Never carry resting structure with surface-raised, border (panel.border),
input-border, or muted (descriptionForeground): themes may leave those unset,
so their resolved defaults form no cohesive hierarchy. surface-raised +
border are for floating surfaces only (popovers, dialogs, theme picker);
derive a hairline from the pair foreground at low alpha or an inset shadow
(Inset-Over-Border). Reference: lib/src/remote/pocket-app/App.tsx.
lib/src/theme-colors.css binds most tokens to a fixed VSCode key. Seven are picked at
runtime instead:
| Token | Pick |
|---|---|
--color-door-bg / --color-door-fg |
whichever pair — inactive-header or terminal bg/fg — sits further from --color-app-bg in OKLab |
--color-focus-ring |
a chromatic focusBorder, else a chromatic active-header background, else the candidate furthest from --color-app-bg; "chromatic" is OKLab chroma ≥ FOCUS_RING_SATURATION_FLOOR |
--color-alarm-vs-{header-active,header-inactive,door,terminal} |
plain white or black, by the OKLab lightness of the background the alert treatment sits on (rationale) |
The terminal alarm tint drives the whole-Pane alarm overlay.
Must derive the Door alarm tint from the newly chosen background in the same
pass. Pinned by lib/src/lib/themes/dynamic-palette.test.ts.
Must refresh dynamic picks on body or html class/style changes and repair
removed inline values. Pinned by lib/src/lib/themes/use-dynamic-palette.test.tsx.
Never fork the dynamic picks: runtime UI and diagnostics must use
pickDoorPair(), pickFocusRing(), and pickAlarmColor().
Source of truth: computeDynamicPalette() in
lib/src/lib/themes/dynamic-palette.ts; OKLab distance/chroma helpers in
lib/src/lib/color-contrast.ts; useDynamicPalette() in
lib/src/lib/themes/use-dynamic-palette.ts (mounted by Wall and
MobileTerminalUi, publishes on document.body); exports in
lib/src/lib/themes/index.ts.
Two layers: --vscode-* holds imported or host-provided VSCode color data;
--color-* in lib/src/theme-colors.css provides the semantic Tailwind tokens
(bg-app-bg, text-app-fg, bg-header-active-bg).
The colour tokens are a file a host can import without the app.
theme-colors.css carries --color-* alone; theme.css layers the type scale,
fonts, animations, and component classes over it. A host rendering library
components inside its own design takes the first and never the second
(rationale).
applyTheme() writes the theme's --vscode-* to document.body, fills missing
consumed variables through the resolver, adds vscode-light / vscode-dark for
consumers that need the theme type, and sets body's color-scheme to that
polarity so native controls (form inputs, scrollbars, autofill) follow the
theme, not the OS. In real VSCode webviews
installVscodeThemeVarResolver() runs before React renders, materializing only
the missing consumed variables on body.style and removing stale materialized
ones once the host provides a real value.
Must reveal the host cascade before resolving formerly missing values, and
cancel queued resolution on disposal. Pinned by
lib/src/lib/themes/vscode-color-observer.test.ts.
Selection backgrounds are flattened to opaque. Theme authors give
list.activeSelectionBackground / list.inactiveSelectionBackground alpha;
Dormouse uses them as solid header and Workspace-tab fills, so applyTheme() composites
them over sideBar.background first (rationale).
A same-object applyTheme() call is a no-op only while the expected inline
--vscode-* variables, color-scheme, and the vscode-light / vscode-dark class are still on
document.body — a fresh object for the same id re-applies, and the id
comparison gates only the listener notification — and ThemePicker re-restores in a layout effect after mount
— React Router document hydration can reconcile those writes away (rationale).
Each layer declares its theme-dependent tokens twice: at document level
(@theme so Tailwind generates utility classes, or :root) and on body, the
runtime source of truth. Every token whose value contains var() — indirect
chains included — must appear at both levels with the same value, since only
the body copy sees what applyTheme() writes to body.style (rationale).
lib/src/lib/themes/consumed-keys.test.ts enforces it per file, because a
host may import either layer alone. The seven
dynamic-palette tokens also carry body-level baselines matching their @theme
declarations, so direct CSS-var consumers (the mobile gesture SVG, an alarm
inset before the first pass) render before useDynamicPalette() publishes
refined values.
Never put hardcoded color defaults or var(..., fallback) chains in
theme-colors.css or theme.css (Host-Theme-Only Rule): hosts plus the resolver provide every
consumed --vscode-* before Dormouse renders.
Color IDs with null registry defaults are materialized component-equivalently,
since Dormouse consumes them as direct CSS variables: null foregrounds inherit
the nearest normal foreground — list.inactiveSelectionForeground takes
sideBar.foreground, then base foreground, never
list.activeSelectionForeground (rationale); null backgrounds inherit the
relevant surface; null borders become transparent, so existing border geometry
does not accidentally draw in currentColor.
Source of truth: applyTheme() in lib/src/lib/themes/apply.ts;
flattenSelectionAlpha() in lib/src/lib/themes/flatten-alpha.ts;
installVscodeThemeVarResolver() in
lib/src/lib/themes/vscode-color-observer.ts; RESOLUTION_RULES in
lib/src/lib/themes/vscode-color-registry.ts.
Terminal content is orthogonal to the chrome: xterm.js reads terminal colors
straight from --vscode-* in getTerminalTheme(), which fills ITheme's
background, foreground, cursor, selectionBackground, and 16 ANSI entries. The
resolver materializes the VSCode terminal defaults first — when unset,
terminal.background inherits editor.background, terminalCursor.foreground
inherits terminal.foreground, terminal.selectionBackground inherits
editor.selectionBackground, and terminal.foreground takes VSCode's terminal
foreground registry default.
getTerminalTheme() carries no per-key default — REGISTRY_DEFAULTS is the
one such table, and every shipping host materializes these keys first; an unset
key is omitted so xterm.js applies its own. Two exceptions: the
background/foreground pair, rostered under DESIGN.md → "Fixed Exceptions", and
cursor, which falls back to the resolved foreground because the three colors
pushed to a DOM-less host must all be present or the push is dropped whole.
A MutationObserver re-reads these on class or style mutations of body or
html, so applying a theme updates existing terminals. Adapters must use the
terminal-theme.ts API directly — it is not re-exported through the
terminal-registry facade. Its themeColorProvider answers OSC 10/11/12 color
queries; the escape contract is terminal-escapes.md's.
Source of truth: getTerminalTheme() in lib/src/lib/terminal-theme.ts.
Bundled and installed themes are DormouseTheme objects in
lib/src/lib/themes/. A theme's vars map holds only consumed --vscode-*
variables plus resolver dependencies (convertVscodeThemeColors() filters
imported VSCode theme JSON to CONSUMED_VSCODE_KEYS), and may omit any key
VSCode itself would omit — completeThemeVars() fills those from registry
defaults and the inheritance rules above.
lib/scripts/bundle-themes.mjs bakes the bundled themes at build time (VSIX from
OpenVSX → unzip → resolve %nls% labels → filter colors → bundled.json +
bundled-extensions.json, both checked in so builds need no network).
lib/src/lib/themes/openvsx.ts does the same in-browser for user-installed
themes, dynamically importing fflate and jsonc-parser to keep them out of
the initial bundle. The build script cannot import TS, so it restates the
consumed-key list; lib/src/lib/themes/consumed-keys.test.ts pins the two.
Never ship a theme in bundled.json without its bundled-extensions.json
record, or keep a record no theme uses — that file is the provenance the
supply-chain page publishes (security-supply-chain.md). Records carry the
extensionId joining them to the <extensionId>.<slug> theme ids;
lib/src/lib/themes/bundled-extensions.test.ts pins both directions.
Must tolerate unreadable storage and failed active-id writes during theme restoration.
Pinned by lib/src/lib/themes/apply.test.ts.
Must discard malformed installed-theme records while retaining valid ones.
Pinned by lib/src/lib/themes/store.test.ts.
subscribeToActiveTheme() notifies only on a different theme, compared by
id, not object identity (rationale). It serves the website tutorial's theme step
(tutorial.md); never reach for onTerminalThemeChange()
instead (rationale).
Source of truth: getInstalledThemes() / getStoredActiveThemeId() /
setActiveThemeId() in lib/src/lib/themes/store.ts;
subscribeToActiveTheme() in lib/src/lib/themes/apply.ts;
onTerminalThemeChange() in lib/src/lib/terminal-theme.ts.
Every host that lets the user pick a theme does it in the Settings dialog, opened from the far right of the baseboard (alarm sections in alert.md); host chrome — the standalone titlebar, the website playground navbar — carries none.
- VS Code offers none at all (rationale).
VSCodeAdaptersets the optionalhostOwnsThemecapability and the dialog hides its Theme row (docs/specs/transport.md→ Adapter model). - Each host restores at boot, since the picker mounts only when the dialog
opens:
standalone/src/main.tsxcallsrestoreActiveTheme(); the website playground pages and Pocket useuseRestoredTheme(), which applies at render init and repeats after commit (hydration again). Pocket passesrestorePocketThemeas itsrestoreargument so the browser-chrome sync rides the same lifecycle. - The two
/playground/pocketmarketing mounts and docs pages keep the free-floatingcompactpicker (rationale), the docs placing it floating atlgand inline in the mobile bar. Both variants show the active theme's preview — beside its label on the dialog trigger, beside the word "Theme" incompact— so trigger and entry read as one control. - The picker renders the bundled default through hydration, then reconciles stored themes and selection in a layout effect (rationale).
- Every candidate previews in its own palette, resolved through
resolveThemeVars— the pathapplyThemepaints, so no preview shows a color the app would not (rationale). Omissions resolve from the candidate's polarity, never the document's, and previewing neither applies a theme nor writes storage. - Must underline hover without visually marking selection, preserving the candidate's palette.
- Chrome outside the previews styles itself in
--color-*utilities. A host rendering library JSX scanslib/srcand importstheme-colors.css, or none of those utilities reach it (rationale). Controls inside one takethemePreviewButton, inheriting the candidate'scurrentColor(Concentric-Corners exception: entry corners against the swatch). onPickreports the choice, not the change.restoreActiveThemepersists the id it resolved, sodormouse:active-themeexists whether or not anyone chose, andsubscribeToActiveThemeis silent on a re-pick. Only the picker reports explicit choices.- The host's fallback theme is module state, not a prop.
setDefaultThemeId()holds it andrestoreActiveTheme()takes no argument, so every path re-resolving the active theme gets the same answer (rationale).useRestoredTheme()latches it before its first restore and ahead of any child render (rationale). - Uninstalling is a single click (
DESIGN.md→ "Don't" banswindow.confirm), matchingWatchedCommandList's remove control in the same dialog, and the picker row'sXkeeps a gap from the row's select target (rationale). useAnchoredMenureturns a dropdown's whole geometry; a caller never re-implements placement beside it — dialog dropdowns on itsfixedstrategy,compactonabsolute(rationale). Must clamp and cap against the visual viewport when the browser exposes it, so mobile browser chrome and the on-screen keyboard stay outside the menu's usable area. Menus close on ancestor scroll and share dismissal with the Shell row. The dialog owns the open state soEscapecloses the menu first, whichModalFrame's capture-phase handler would otherwise swallow.- Heights follow the viewport, never a fixed pixel budget: both surfaces cap
at
OVERLAY_MAX_HEIGHT(dialog.modal, dropdown.popover). The list's cap is a ceiling, not a floor — the panel cap shrinks it on a short screen while the footer stays put. Pinned by theOpenOnShortViewportstory, not a unit test (rationale).
Source of truth: ThemePicker in lib/src/components/ThemePicker.tsx;
ThemeList in lib/src/components/theme-picker/ThemeList.tsx;
getThemePreview in lib/src/components/theme-picker/ThemePreview.tsx;
getThemeSwatchColors in lib/src/components/theme-picker/ThemeSwatch.tsx —
each pinned by the test beside it except getThemePreview, covered by
lib/src/components/theme-picker/ThemeList.test.tsx; resolveThemeVars in
lib/src/lib/themes/apply.ts; themePreviewButton in lib/src/components/design.tsx;
lib/src/components/SettingsDialog.tsx; setDefaultThemeId() /
restoreActiveTheme() in lib/src/lib/themes/apply.ts; useRestoredTheme() in
lib/src/lib/themes/use-restored-theme.ts; restorePocketTheme in
lib/src/remote/pocket-app/pocket-theme.ts; useAnchoredMenu /
useCloseOnOutsideAndEscape in lib/src/components/use-anchored-menu.ts;
OVERLAY_MAX_HEIGHT* in lib/src/components/design.tsx; the colour import,
@source, and its exclusions in website/src/index.css.
Must scan standalone/src in lib/.storybook/preview.css for host utilities.
lib/.storybook/themes.ts builds the switcher's color maps from bundled.json
and must run them through completeThemeVars() and flattenSelectionAlpha()
(with applyTheme()'s host typography defaults) so isolated stories see the
materialized --vscode-* set the app sees. The preview decorator writes them to
both html (VSCode's host globals) and body (matching applyTheme()), and
publishes the dynamic palette through computeDynamicPalette() so stories
outside a full Wall — doors, focus rings, alarm insets — still get the runtime
picks. PREFERRED_STORYBOOK_THEME in lib/.storybook/preview.ts names
the default simulated host theme, falling back to the first bundled theme so
a renamed or removed bundle cannot leave stories without theme vars.
The Theme Debugger serves VSCode, standalone, and the website
playground. Never mutate theme storage or terminal colors — snapshot
DOM-visible state: theme metadata, consumed color --vscode-* tagged
host-provided vs Dormouse-materialized with its declaration site and resolver
trace, static --color-* tokens with their bound key, the terminal palette xterm.js
reads, and Door/focus-ring picks with candidate metrics and a prose reason
(ThemeDiagnosticSnapshot owns the shape). The copied report dumps
the same snapshot. A real VSCode webview shows only the inferred theme
kind, since VSCode exposes CSS variables and not raw built-in theme JSON.
Every host reaches it as Debug current theme in the ThemePicker menu, so on
/playground/pocket it rides the compact variant (two mounts, defaulting to
Kimbie Dark; /pocket redirects before rendering a picker). VSCode has no picker
and opens it through the dormouse.debugTheme command and the
dormouse:openThemeDebugger extension-to-webview message.
Source of truth: captureThemeDiagnostics() / ThemeDiagnosticSnapshot in
lib/src/lib/themes/diagnostics.ts.