| name | Dormouse | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | A mouse-friendly multitasking terminal that feels native inside VSCode. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| colors |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typography |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rounded |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spacing |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| components |
|
Creative North Star: "The Native Tenant"
Dormouse is a tenant in someone else's house. The house is VSCode. The user picked the furniture (their theme), the lighting (their mode), the typography (their editor font). Dormouse moves in, multiplies what the user can do with their terminals, and leaves the decor alone. The interface should be indistinguishable from a built-in panel: not because it imitates VSCode, but because it inherits from VSCode. Every color, every font, every surface is a passthrough of the host's tokens.
The system is intentionally minimal and bg-only. Chrome recedes; terminals are the content. Hierarchy is conveyed through background shifts between header-active-bg and header-inactive-bg, not through borders, shadows, or accent stripes. Status is conveyed through shape and position (a bell icon, a door's alert state) and through the active terminal palette's own ANSI red/green/yellow, not through a separate design-system palette.
The system explicitly rejects: rounded SaaS cards, gradient accents, hacker-aesthetic green-on-black, "Slack-style" Electron chrome bloat, decorative animations, and any token that hardcodes a color. If a user installs a high-contrast theme, the chrome can look flatter than usual: that is accepted, not "fixed" with overrides.
Scope. This design system governs every product surface: the lib components, the VS Code webview, the standalone app, and the server-served Pocket app — auth screens included (docs/specs/pocket-app.md). The marketing website keeps its own separate "homepage" design system (website/src/index.css: Ubuntu Mono / Ubuntu Sans Mono, a fixed dark palette, the caramel accent), scoped to marketing pages only. The two share nothing except lib components embedded in the site's playgrounds, which bring this system's tokens with them; product surfaces never consume homepage tokens, and marketing chrome never consumes --vscode-*.
Key Characteristics:
- Host-theme-driven palette: every color is a
var(--vscode-*)passthrough. - Bg-only chrome: no decorative borders, no resting shadows, no accent stripes.
- Monospace everywhere: sans and mono resolve to the same VSCode editor font.
- Tight type scale:
text-xs(10px) andtext-sm(12px) override Tailwind defaults; almost everything sits on these two steps. - Status through palette already in the room: alerts use the user's ANSI red / green / yellow, not a brand color.
- Motion: short, exponential ease-out for layout transitions; sparing spring-overshoot reserved for celebratory state-resolution.
The palette has no fixed values. Every semantic token resolves to a --vscode-* variable at runtime. Inside VSCode, those variables are injected by the host. Outside VSCode (standalone, website playground), applyTheme() materializes the same variable shape on document.body from a bundled Dormouse theme.
This system has no "primary" accent in the brand sense. The closest analogue is the focused-header pair:
- Header Active Background (
var(--vscode-list-activeSelectionBackground)): the bg of the focused pane's header tab. This is the only consistent visual cue for "this pane has focus." Used at full opacity for the header bg, and at/25opacity for the active terminal selection ring and copy-confirm flash background. - Header Active Foreground (
var(--vscode-list-activeSelectionForeground)): text on the focused header. Inherited by buttons inside the focused header.
- Header Inactive Background (
var(--vscode-list-inactiveSelectionBackground)): unfocused pane headers and the candidate bg for doors. The bg-bg delta between this andheader-active-bgis the entire focus affordance. - Header Inactive Foreground (
var(--vscode-list-inactiveSelectionForeground)): text on unfocused headers. Inherits the nearestsideBar.foreground, not the active-selection white.
- Door Background / Foreground (
var(--color-door-bg)/var(--color-door-fg)): runtime-chosen at body level bycomputeDynamicPalette(). Picks whichever of (inactive-header bg/fg) or (terminal bg/fg) has stronger OKLab perceptual separation from the app bg, so doors stay readable regardless of how close the user's chosen header and terminal palettes happen to be. - Focus Ring (
var(--color-focus-ring)): runtime-chosen. Prefers a chromaticfocusBorder, then a chromatic active-header background, then the highest-contrast fallback. Used for the marching-ants command-mode ring and the terminal text-selection border.
- App Background (
var(--vscode-sideBar-background)): the chrome host. Baseboard, Lath sashes, gaps around panes. Reads as "the editor's sidebar," because it literally is. - App Foreground (
var(--vscode-sideBar-foreground)): default text on app chrome. - Surface Raised (
var(--vscode-editorWidget-background)): popovers, tooltips, dialogs, kill-confirm sheet, theme picker dropdown. Roughly one step above app-bg in the host's vocabulary. - Foreground (
var(--vscode-editor-foreground)): primary text in raised surfaces (popups, dialogs). - Muted (
var(--vscode-descriptionForeground)): secondary text, shortcut hints inside[brackets], theme picker chip captions. - Border (
var(--vscode-panel-border)): hairline border on raised surfaces (popups, dialogs, theme picker). The only place borders carry weight.
- Terminal Background / Foreground (
var(--vscode-terminal-background)/var(--vscode-terminal-foreground)): the terminal content surface and xterm default text. Orthogonal to the chrome. - Error (
var(--vscode-terminal-ansiRed)): destructive actions and kill-confirm letter flash. - Success (
var(--vscode-terminal-ansiGreen)): TODO check, theme-store install confirm. - Alarm (
var(--vscode-terminal-ansiYellow)baseline; runtime-overridden): alert tint.computeDynamicPalette()replaces each--color-alarm-vs-*token with plain white or black by the OKLab lightness of its background (active header, inactive header, Door, or terminal body), so ringing bells and the whole-Pane spoken-alarm treatment stay maximally legible on any surface.
Every literal color the Host-Theme-Only Rule below permits, in full. Each is here because the surface it paints is not read as part of the theme; a literal anywhere else is a bug.
- Window Close Hover (
#b92a1b): native OS close-button hover on Windows/Linux chrome buttons; matches the platform convention across themes. - Setup QR (
#ffffffground,#000000modules, inlib/src/components/QrCode.tsx): a phone camera reads this control, not a person. Scanners expect dark-on-light and many refuse an inverted code, and no theme token promises either the polarity or the contrast ratio in both light and dark.
The Host-Theme-Only Rule. Never write a hex value or oklch() literal into theme-colors.css, theme.css, or a component. Never use var(..., fallback) chains. Every color must resolve through --vscode-* or one of the body-published runtime picks (--color-door-*, --color-focus-ring, --color-alarm-vs-*). The only exceptions are the ones rostered under Fixed Exceptions above, and adding one means adding it there.
The Bg-Only Chrome Rule. Pane headers, doors, and the baseboard convey hierarchy through background shifts only. Do not add borders or shadows to "make the hierarchy work." If a high-contrast theme makes a header look flat against the app bg, that is the user's theme speaking; do not override.
The Active Header Doubles as Accent Rule. Animated emphasis (copy-confirm flash, active-pane selection ring) tints with header-active-bg/25. The accent is not separate from the focus signal; they are the same color, used at different opacities.
Display Font: none (no display tier).
Body Font: var(--vscode-editor-font-family).
Label/Mono Font: same as body. Sans and mono resolve to the same VSCode editor font.
Character: monospace, the user's own editor face. The system has no opinion about Cascadia vs. SF Mono vs. JetBrains Mono vs. Fira Code; whatever is set in the editor is what Dormouse uses, including ligature settings. This is the typographic equivalent of the host-theme rule.
- Body (weight 500,
text-sm= 0.75rem / 12px, line-height 1rem): pane headers, doors, popup contents, button labels. The single most-used step. - Label (weight 600,
text-xs= 0.625rem / 10px, line-height 1rem,tracking-[0.08em]): TODO pills, kill-confirm hint, shortcut prompts. The wider tracking is non-negotiable at this size; without it the pill characters smear together. - Shortcut (weight 500,
text-sm, muted color): keybinding text inside[brackets]. Always rendered with theShortcutcomponent orrenderShortcuts(), never inline.
The Tailwind defaults for text-xs (12px) and text-sm (14px) are overridden in theme.css to 10px and 12px respectively. The override is intentional: the chrome needs to recede, and the default Tailwind sizes are too loud against terminal output.
The Two-Step Rule. Almost everything sits on text-xs or text-sm. If a new surface wants text-base or larger, the surface is probably wrong. Make it smaller or rethink the affordance.
The Bracket-Shortcut Rule. Keybindings always render as [k] in muted color via <Shortcut> or renderShortcuts(...). Never put Ctrl+K or ⌘K in chrome text. The bracket convention is the entire visual hint system for "this is a key."
Flat by default. Pane headers, doors, the baseboard, and terminal panes carry zero shadow at rest. Hierarchy is delegated to background shifts (active vs. inactive header) and to position (doors sit on the baseboard, the baseboard sits below panes).
Shadows appear only on raised surfaces that float above content: popovers, tooltips, dialogs. They are ambient, not structural; they say "I am temporary and on top," not "I have weight."
- Popover (
box-shadow: var(--tw-shadow-md)): tooltips (PopupButtonRow), selection popup, illegal-rename warning, terminal-pane header tooltips. - Dialog (
box-shadow: var(--tw-shadow-lg)): kill-confirm sheet, TODO alert dialog. - Modal (
box-shadow: var(--tw-shadow-2xl)): theme picker dropdown (when expanded), theme debugger, theme store dialog. - Inset hairline (
box-shadow: inset 0 0 0 1px var(--color-focus-ring)/var(--color-border)): mobile UI segmented controls. Used instead ofborderwhen the surface needs a 1px stroke that does not shift layout on state change.
The Flat-At-Rest Rule. Surfaces in the resting layout (panes, doors, baseboard, terminal content) carry no shadow. Shadow appears only when a surface enters the air (popover, tooltip, dialog, modal).
The Inset-Over-Border Rule. When a surface needs a 1px stroke that may toggle on state change (active vs. inactive), prefer shadow-[inset_0_0_0_1px_…] over border. The shadow does not affect layout; the border does.
The Concentric-Corners Rule. When a rounded corner nests inside another rounded corner (a focus ring wrapping a pane, an outline hugging a rounded surface), both arcs must share a corner center: outer radius = inner radius + the gap between them. Resolve violations by growing the outer radius — never tighten the inner one. Rings at zero offset keep the wrapped element's radius. Theme-picker exception: docs/specs/theme.md → "Where the user picks a theme". Source of truth: the SELECTION_RING_INFLATE_PX / PANE_SELECTION_RING_RADIUS_PX derivation in lib/src/components/design.tsx.
Doors are the pane-header indicators on the baseboard. The most signature component in the system.
- Shape: top corners only —
rounded-t-lg(8px). The bottom is square so the door visually anchors to the baseboard. The pane body owns the bottom corners (rounded-b-lg); together they form one continuous rounded rectangle when expanded. - Surface:
bg-door-bg+text-door-fg. These resolve at runtime viacomputeDynamicPalette()and may match either the inactive-header palette or the terminal palette, whichever has stronger separation fromapp-bg. - Dimensions:
h-6(24px),min-w-[68px],max-w-[220px]; the title button padspl-2.5(10px),gap-2between its glyph, title, and badges, endingpr-2.5alone orpr-1when the notepad button follows it. The notepad button carries the trailing inset itself (pl-0.5 pr-2). - Type:
text-sm font-medium font-mono. - Content: leading browser-display icon cluster on a browser Surface (
size={12}each,gap-0.5— a wide robot plus the presentation glyph, or the presentation glyph alone foriframe; named in the Door's accessible name,docs/specs/dor-browser.md→ Browser Chrome); truncated title; optional TODO pill (text-xs font-semibold tracking-[0.08em], success-tinted when flourishing); optional bell icon (size={11},weight="fill"),text-alarm-vs-doorwhen ringing; trailing notepad button (size={12},weight="fill") when the minimized Surface holds notes. - Spoken alarm:
SPEAKINGinverts and pulses the whole Door and takes the badge slot for its speaker-plus-label — it lasts one utterance.SPOKENpersists until the ring is attended, so it keeps a static 2px inset and adds its speaker icon beside the TODO pill and bell instead of evicting them. Both carry a speaker icon (shape, not color) and name the state in the accessible name. - Hover/Focus: no decorative hover on the door itself; the focus state is conveyed by the parent pane's selection ring, not by a per-door treatment. The door is a labelled
role="group"wrapper holding one or two buttons rather than one button — the title button reattaches, the notepad button opens the popover and does not (docs/specs/notepad.md→ Notepad UI) — and only the notepad button takes the standardhover:bg-current/10wash.
The icon-and-tooltip button used inside pane headers (kill, alert toggle, todo, etc.).
- Shape:
rounded(4px) when icon-only, alsoroundedfor labeled variants. - Color:
text-inherit— inherits the header's foreground, so it tints with the active/inactive header palette. - Hover:
hover:bg-current/10— a 10%-opacity wash of the current text color. Theme-agnostic, works light or dark. - Tooltip: rendered through a portal as a
PopupButtonRow8px below the button, withtext-smprimary line and an optional muted detail line. Keybindings inside the tooltip auto-render as[bracketed]shortcuts.
The flat segments inside a PopupButtonRow — the row owns the border, background, shadow, and text-sm, so a segment contributes only padding and state. Every segment currently inherits the row's foreground; these rows offer rather than ask, so none of them carries an emphasized action.
- Hover:
hover:bg-foreground/10, a wash over the row's surface. - Flash:
flashedswaps inanimate-copy-flashwithbg-header-active-bg/25for copy-confirm moments.
The Windows/Linux native-style window control row in the standalone app bar.
- Variants:
icon(h-5 min-w-5, hover bg-current/10),labeled(h-5 min-w-5 px-1.5 text-xs),window(w-11, hover bg-current/10),windowClose(w-11, hover bg#b92a1btext-white). - The exception:
windowCloseis one of the two rostered literal hex colors (Fixed Exceptions), because the platform convention is a hard red regardless of theme.
The system uses raised surfaces, not "cards." There are no nested cards. There is no resting card grid.
- Raised surface (
PopupButtonRow, tooltips, popups):bg-surface-raised,border border-border,rounded(4px),shadow-md,font-mono text-sm. - Dialog (
KillConfirm, the terminal context's detail dialogs):bg-surface-raised,border border-border,rounded-lg(8px),shadow-lg, generous padding (px-6 py-4for kill-confirm). - Modal (
ThemePickerdropdown,ThemeDebugger,ThemeStoreDialog):bg-surface-raised,border,rounded,shadow-2xl, fixed-position with viewport-clamped sizing. - On-palette surface (theme previews): a surface painting a previewed palette rather than the host's, so its controls take
themePreviewButtonand inheritcurrentColorinstead oftext-muted/hover:bg-foreground/10/outline-focus-ring.docs/specs/theme.md→ "Where the user picks a theme" owns candidate palettes, selection, and scroll affordances.
The Viewport-Bound Rule. Anything floating over the viewport takes its height cap from OVERLAY_MAX_HEIGHT in design.tsx — .modal for a ModalFrame surface (the viewport minus MODAL_OVERLAY_INSET doubled), .popover for an anchored overlay (matching clampOverlayPosition's margin). Don't hand-write a vh/dvh literal at the call site: the six that predated this token had drifted to five different budgets, and one silently shadowed its own overlay's padding. Each entry reads its own custom property first (--overlay-max-h-modal / --overlay-max-h-popover), so a story — or a host with less room than the window — can narrow one bound without touching the component. They are deliberately separate: a popover inside a modal is a DOM descendant of it, and custom properties inherit, so one shared knob would cap the dialog too. A deliberately smaller budget than the viewport (a context menu at max-h-[70vh]) is a different decision and stays at the call site.
- Used by
ThemePicker. Style:bg-input-bg,border border-input-border,rounded,font-mono,text-sm. - Focus: native browser focus outline; this is acceptable because the entire input lives inside a raised surface that already has
shadow-2xland a border. - Form fields inside a dialog use the underlined pair in
design.tsxinstead, so a form mixing them reads as one:NumericInputfor a number (filtered at the keystroke, sized inch) andTextInputfor a string (full width,typepassed through —type="password"for a credential). The app has no checkbox anywhere: a boolean is anOnOffSwitch. - On/off switch: a compact track with the thumb left when off and right when on, followed by only the current
On/Offlabel. Off is neutral; on uses the host link accent. Its 60×24px button uses the subdued action tint and hover fromdesign.tsx, with native keyboard and disabled-fieldset behavior. Nested settings text aligns throughUNDER_SWITCH_INDENT.
The system has no traditional product top-nav. Three surfaces play navigational roles:
- Workspace strip (standalone app bar, top): horizontal tabs, one per Workspace, for switching between Workspaces within one window. Inactive tabs carry the union alert/TODO indicators (bell + TODO pill) borrowed from the Door vocabulary; the active tab carries none. This is standalone app-bar chrome around the Wall — see
docs/specs/layout.mdanddocs/specs/alert.md— and its exact visual treatment is being designed in Storybook. VS Code surfaces the same status on its own native tab/badge chrome instead (docs/specs/vscode.md). - Baseboard (bottom of the app): horizontal strip of doors representing minimized panes plus chrome action buttons. Doors are the primary navigation affordance to a minimized terminal. Buttons use
chromeButtonwith 24px height, muted text, andhover:text-foreground; Settings icons use square buttons with 2px gaps, while labeled overflow buttons keep horizontal padding. - Pane Header (TerminalPaneHeader): the tab-replacing strip at the top of each pane. Lath is a headless tiling engine with no tab-bar chrome of its own; the React header IS the tab.
A tiny inline label that appears in pane headers and inside doors when a terminal has a TODO state.
text-xs font-semibold tracking-[0.08em]— the tracking is mandatory at this size.- Grid-stacked
<letters>and<check>so width stays stable when the dismiss flourish runs. - Flourish (500ms): letters fade 0–30%, check springs in 0–40% with
cubic-bezier(0.34, 1.56, 0.64, 1)(overshoot 1.15x, settle to 1.0x at 55%), whole pill dissolves 55–100%. Reduced-motion replaces the entire sequence with opacity:0 at zero duration.
The most distinctive motion in the system. Implemented as clip-path reveals, not transforms, so getBoundingClientRect stays accurate during the animation (the selection overlay measures real bounds).
- Spawn: 440ms
cubic-bezier(0.22, 1, 0.36, 1)clip-path reveal from left / top / top-left, depending on which side of the layout the new pane appeared on. - Kill (edge/middle): 440ms
pane-fade-out. The neighbor's spawn-grow carries the directional cue; the dying pane just fades. - Kill (last-pane): 440ms
pane-fade-and-shrink-to-br, paired withring-shrink-to-brso the focus ring stays glued to the pane as it disappears. - Reduced-motion: all of the above are nulled.
The selection ring around the focused pane in command mode is an SVG with stroke-dasharray and a marching-ants keyframe that increments stroke-dashoffset by var(--march-offset). Color: var(--color-focus-ring). It is the visual signature of "you are now in command mode," so it marches in a short burst on entry and on each selection change rather than forever — an idle wall runs no animation (timing in docs/specs/layout.md → Selection overlay). The ring stays crisp while travelling; motion reads instead from soft directional bands drawn behind each edge, sized by how fast that edge is moving across itself.
When selection moves between panes/doors, the focus ring glides to the new target over 220ms (FOCUS_MOTION_MS, half the pane-motion duration) on the house curve cubic-bezier(0.22, 1, 0.36, 1), and the source/destination pane headers crossfade their active/inactive palette over the same 220ms (HEADER_PALETTE_TRANSITION_CLASS in design.tsx), so the two read as one gesture. The ring's rect is a per-frame JS tween (rect-tween.ts), not a CSS transition; same-identity re-measures (sash drag, window resize, animator frames) snap 1:1, and a pane↔door move lerps the corner radii so the shape never pops. Reduced motion nulls both: the ring snaps and the header palette swaps instantly.
- Do route every color through a
--vscode-*variable or a body-published runtime pick (--color-door-*,--color-focus-ring,--color-alarm-vs-*). The frontmatter is the contract. - Do keep chrome on
text-xs(10px) andtext-sm(12px). The Tailwind defaults are overridden for a reason; do not writetext-baseinto chrome to "make it readable." - Do use
hover:bg-current/10for neutral hover feedback inside theme-tinted chrome. It is the one hover treatment that always works. - Do convey active-vs-inactive pane state through the
header-active-bg/header-inactive-bgbackground swap. That is the entire focus affordance. - Do render keybindings as
[k]via<Shortcut>/renderShortcuts. Always muted, always bracketed. - Do gate every keyframe animation behind
@media (prefers-reduced-motion: reduce). The pattern is intheme.cssalready; reuse it. - Do use
shadow-[inset_0_0_0_1px_var(--color-…)]instead ofborderwhen a stroke may toggle on state change. Border shifts layout; inset shadow does not. - Do treat doors as the navigation primitive. Doors own
rounded-t-lg; terminal bodies ownrounded-b-lg; together they form one rectangle. - Do use the spring-overshoot curve
cubic-bezier(0.34, 1.56, 0.64, 1)only for state-resolution moments (TODO check, kill confirm, copy flash), and keep durations short (220–500ms).
- Don't write a hex color outside the Fixed Exceptions roster above — and adding one is an edit to that roster, not a local judgement call. No
oklch()literals either; even those bypass the host theme. - Don't add
var(--vscode-*, #fallback)fallback chains intheme-colors.cssortheme.css. The runtime host plus the resolver are responsible for providing the variable; a fallback hides a real bug. - Don't add borders or shadows to pane headers or doors to "make the hierarchy work." The hierarchy is
header-active-bgvs.header-inactive-bg. If a high-contrast theme makes that look flat, accept it. - Don't introduce a
text-mutedcolor inside an active or inactive pane header. Header-internal text inherits the header foreground; muting inside it breaks the focus signal. - Don't use rounded SaaS cards, gradient accents, gradient text, or glassmorphism. PRODUCT.md names these directly: "Generic SaaS (rounded cards, gradients, startup illustrations)," "Electron bloat (Slack — heavy, slow-feeling, too much chrome)."
- Don't use hacker-aesthetic green-on-black, terminal-cliché Matrix tints, or any color that signals "this is a programmer tool." The user's theme decides what color this tool is.
- Don't animate layout properties (
width,height,top,left,padding) with a CSS transition. Pane transitions useclip-pathandopacitydeliberately so layout measurements stay valid mid-animation. The one carve-out is a JS tween that writes true per-frame values on apointer-events: noneoverlay (the Lath animator; the focus ring'srect-tween): it moves through real intermediate geometry every frame rather than letting the browser interpolate an opaque box, so measurements stay valid — a CSStransition: top/left/width/heightdoes not qualify and stays banned. - Don't add an emoji, mascot, or illustration to chrome. PRODUCT.md is explicit: "Overly playful (too many animations, emojis, mascots)."
- Don't gate app chrome on
window.alert/confirm/prompt. Native dialogs are not dependable in the desktop webview: the theme uninstall was gated onconfirmand silently did nothing there, because the call returned without ever showing a dialog. Whether a given webview suppresses the panel or never implements it, a control gated on one cannot be trusted to run. UseModalFrame, or make the action a single click when it is cheap and reversible. The marketing website is exempt — it only ever runs in a real browser, whereShareUrlButton'spromptis a reasonable last-resort clipboard fallback. - Don't wrap things in containers. Most surfaces don't need one; the host's sidebar already is the container.
- Don't introduce a new pass-through
--mt-*token or a one-off color for tabs, badges, accents, or button hovers. If a new rendered surface truly needs a token that isn't in the hierarchy above, updatetheme-colors.cssanddesign.tsxtogether, document the addition indocs/specs/theme.md, and updateCONSUMED_VSCODE_KEYSinbundle-themes.mjs.