# Style Guide · 设计令牌 Design tokens for `dsh-claude-style`, recreating the Claude Code Desktop aesthetic. ## Where the values live This page states the rule each look follows and the reason behind it. A value lives where it is used: colours, durations, easings and layers in `packages/client/src/theme/tokens.json`, a component's lengths in the stylesheet beside its feature, the mascots' frames in `packages/client/src/features/mascot/sheets.ts`, the page-scale arithmetic in `packages/client/src/shared/page-zoom.ts` and `page-scale.ts`. A rule that turns on one value quotes its token name; everything else is read from the file the rule names (D48). The three tables marked generated are written from `tokens.json` by `npm run build`; edit the JSON, not the tables. ## Palette Every look is a **colour card**: an accent, a canvas, a sidebar and the reader's bubble per scheme, an inline-code ink and, when its prose links keep a family of their own, a link per scheme. The first card is the default look. A card's dark outlines are drawn either as a lit highlight or as a flat surface step, and its brand art takes its colour from the ink or the accent (D30). The colour every token paints is a **tone** of one of those seeds; the build resolves the tones into plain colours, so the page ships literals and nothing derives at runtime. The ladder is shared by every card; a card states only its seeds, its sidebar, its bubble and its traits. The reader authors a card of their own: four colours, shown and edited in the settings page beside the two brands. Every card can be picked; a brand card paints its own colours whatever the brand mark is, and a picked card offers 「恢复跟随品牌」 to hand the colours back to the theme style's brand. The reader's palette is the same ladder written as relative colours, one block per brand and scheme, so the colours resolve in the page while the brand cards keep their literals; the image-colour choice (`Default`, `Accent`, `Text colour`) repaints the brand art from the same table (D30). Rules: - Never pure black, never a cold gray. Each canvas pair — the Claude card's and the DeepSeek card's — is read as one family: the canvas, its sidebar, its borders and every ink step around them derive from that card's seed. The reader's own message plate is the card's `bubble` fill, so a conversation bubble reads as the card that painted it. - Accent usage stays under ~10% of visible elements. - Literal colours stay outside the ladder: the selection ring, the ripple's front, the peak and off-peak badges and the popover shadow. **The DeepSeek card** turns both schemes blue. Its accent is DeepSeek's brand blue, the colour of its whale logo and of Deepy; the sidebar's whale and wordmark and the hero's fish take it as their ink. Its light canvas is a faint blue-white with a light blue reader's bubble, its dark canvas a blue-black, and its dark outlines are surface steps rather than highlights. The skin's Claude marks (the account row's picture without an avatar) become DeepSeek's whale (`packages/assets/src/brand/deepseek-mark.svg`, the host's `FISH_LOGO_PATH`); the reasoning slider's accent follows whichever palette is in force. **Host token bindings.** The skin supplies its palette through the host's own alias tokens, so a host control that reads more than one token for one surface has to be given all of them. A filled primary button is that case: the host takes the fill from `--dsw-alias-brand-primary`, but the hover from a monochrome step of its own scale and the label from a foreground token the skin leaves alone. `--dsw-alias-button-primary-fill` and `--dsw-alias-button-primary-hover` therefore state both steps in each card. Two fills are palette-specific rather than carried over from the dark base: a solid chip, lighter than the canvas in light, and an elevated fill, a surface above the canvas in light and a raised gray in dark. The tables below are generated from `packages/client/src/theme/tokens.json` by `npm run build`; edit the JSON, not the tables. A token's row names the tone it reads and the colour that tone resolves to in both cards and both schemes; `—` is a token a card leaves unset. | Card | Accent | Canvas light | Canvas dark | Sidebar light | Sidebar dark | Bubble light | Bubble dark | Link light | Link dark | Edges | Logo ink | Code light | Code dark | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | claude (default) | `#d97757` | `#ffffff` | `#191919` | `#fafafa` | `#1f1f1f` | `#f4f2f3` | `rgba(255, 255, 255, 0.06)` | `#184f95` | `#8ab4f8` | highlight | ink | `#161616` | `#fdfdfd` | | deepseek | `#4d6bfe` | `#fbfcff` | `#13161d` | `#f7f9ff` | `#13161d` | `#ecf0ff` | `#262b36` | `#3b56d9` | `#8fa4ff` | surface | accent | `#0f1115` | `#eef1f8` | | Tone | Seed | Light | Dark | | --- | --- | --- | --- | | `canvas` | | `Δl 0` | `Δl 0` | | `surface-1` | | `Δl 0` | `Δl +0.036` | | `surface-2` | | `Δl 0` | `Δl +0.065` | | `surface-3` | | `Δl +0.002` | `Δl +0.104` | | `overlay` | | `Δl +0.01 · C 0` | `Δl +0.065` | | `code-block` | | `Δl +0.01 · C 0` | `Δl +0.036` | | `inset` | | `Δl +0.01 · C 0` | `Δl -0.024` | | `card` | | `Δl 0` | `Δl +0.044` | | `table-head` | | `Δl -0.034` | `Δl +0.065` | | `hover-plate` | | `Δl -0.034` | `Δl +0.104` | | `selector` | | `Δl -0.004` | `Δl +0.104` | | `chip` | | `Δl -0.019` | `Δl 0` | | `sidebar` | | `Δl -0.002 · ΔC +0.002` | `Δl 0` | | `ink` | | `Δl -0.801` | `Δl +0.781` | | `ink-2` | | `Δl -0.474 · ΔC +0.01` | `Δl +0.571 · ΔC +0.009` | | `ink-3` | | `Δl -0.365 · ΔC +0.011` | `Δl +0.458 · ΔC +0.012` | | `ink-4` | | `Δl -0.251 · ΔC +0.007` | `Δl +0.336 · ΔC +0.01` | | `bubble` | | `Δl -0.037 · ΔC +0.002` | `Δl +0.09 · α 0.06` | | `bubble-flat` | | — | `Δl +0.088 · ΔC +0.006` | | `border-1` | | `Δl -0.067` | `Δl +0.026 · α 0.09` | | `border-2` | | `Δl -0.098` | `Δl +0.026 · α 0.18` | | `border-3` | | `Δl -0.144` | `Δl +0.026 · α 0.3` | | `border-1-flat` | | — | `Δl +0.069` | | `border-2-flat` | | — | `Δl +0.108` | | `border-3-flat` | | — | `Δl +0.161` | | `divider` | | `Δl 0 · α 0.05` | `Δl +0.026 · α 0.06` | | `scrollbar` | | `Δl -0.128 · α 0.9` | `Δl -0.63 · α 0.9` | | `scrollbar-hover` | | `Δl -0.368 · α 0.8` | `Δl -0.446 · α 0.9` | | `on-accent` | | `Δl 0` | `Δl +0.775` | | `inverse-ink` | | `Δl +0.011` | `Δl -0.011` | | `hover-wash` | | `Δl -0.081 · α 0.08` | `Δl +0.026 · α 0.08` | | `code-wash` | | `Δl -0.081 · α 0.05` | `Δl +0.026 · α 0.05` | | `shimmer` | | `Δl 0` | `Δl 0` | | `accent` | | `Δl 0 · C ×1` | `Δl 0 · C ×1` | | `accent-hover` | | `Δl -0.063 · C ×1.01` | `Δl +0.071 · C ×0.91 · h +2` | | `accent-active` | | `Δl +0.068 · C ×1.1 · h +1 · α 0.26` | `Δl 0 · C ×1 · α 0.3` | | `accent-tint` | | `Δl 0 · C 0.028` | `Δl 0 · C 0.055` | | `ui-link` | | `Δl -0.069 · C ×0.95` | `Δl +0.1 · C ×0.72` | | `ui-link-hover` | | `Δl -0.143 · C ×0.9` | `Δl +0.19 · C ×0.7` | | `link` | | `Δl 0 · C ×1` | `Δl 0 · C ×1` | | `link-underline` | | `Δl 0 · C ×1 · α 0.6` | `Δl 0 · C ×1 · α 0.6` | | Token | Tone | claude light | claude dark | deepseek light | deepseek dark | Host alias | Use | | --- | --- | --- | --- | --- | --- | --- | --- | | `--dsw-alias-bg-base` | `canvas` | `#ffffff` | `#191919` | `#fbfcff` | `#13161d` | host's own | | | `--dsw-alias-bg-layer-1` | `surface-1` | `#ffffff` | `#212121` | `#fbfcff` | `#1b1e26` | host's own | | | `--dsw-alias-bg-layer-2` | `surface-2` | `#ffffff` | `#282828` | `#fbfcff` | `#22252d` | host's own | | | `--dsw-alias-bg-layer-3` | `surface-3` | `#ffffff` | `#323232` | `#fcfdff` | `#2b2f37` | host's own | | | `--dsw-alias-bg-overlay` | `overlay` | `#ffffff` | `#282828` | `#ffffff` | `#22252d` | host's own | | | `--dsw-alias-border-l1` | `border-1` / `border-1-flat` | `#e9e9e9` | `rgba(255, 255, 255, 0.09)` | — | `#23262e` | host's own | the standard edge: card and panel outlines, dividers, field outlines | | `--dsw-alias-border-l2` | `border-2` / `border-2-flat` | `#dedede` | `rgba(255, 255, 255, 0.18)` | — | `#2c3038` | host's own | | | `--dsw-alias-border-l3` | `border-3` / `border-3-flat` | `#cfcfcf` | `rgba(255, 255, 255, 0.3)` | — | `#3a3d46` | host's own | | | `--dsh-claude-divider` | `divider` | `rgba(22, 22, 22, 0.05)` | `rgba(255, 255, 255, 0.06)` | `rgba(19, 20, 22, 0.05)` | `rgba(251, 255, 255, 0.06)` | `var(--dsw-alias-border-l1)` | a line that only separates: foot areas, settings rows, menu separators, the transcript's rule | | `--dsw-alias-brand-primary` | `accent` | `#d97757` | `#d97757` | `#4d6bfe` | `#4d6bfe` | host's own | the single accent: checks, focus rings, selected marks | | `--dsw-alias-brand-text` | `on-accent` | `#ffffff` | `#fbfbfb` | `#fbfcff` | `#f2f7ff` | host's own | | | `--dsw-alias-button-primary-fill` | `accent` | `#d97757` | `#d97757` | `#4d6bfe` | `#4d6bfe` | host's own | | | `--dsw-alias-button-primary-hover` | `accent-hover` | `#c46343` | `#eb9170` | `#3d56e9` | `#6a84ff` | host's own | | | `--dsw-alias-button-elevated-fill` | `overlay` | `#ffffff` | `#282828` | `#ffffff` | `#22252d` | host's own | | | `--dsw-alias-button-floating-fill` | `overlay` | `#ffffff` | `#282828` | `#ffffff` | `#22252d` | host's own | | | `--dsw-alias-button-floating-hover` | `surface-3` | `#ffffff` | `#323232` | `#fcfdff` | `#2b2f37` | host's own | | | `--dsw-alias-button-info-fill` | `accent` | `#d97757` | `#d97757` | `#4d6bfe` | `#4d6bfe` | host's own | | | `--dsw-alias-button-info-hover` | `accent-hover` | `#c46343` | `#eb9170` | `#3d56e9` | `#6a84ff` | host's own | | | `--dsw-alias-interactive-bg-active` | `accent-active` | `rgba(246, 136, 98, 0.26)` | `rgba(217, 119, 87, 0.3)` | `rgba(95, 124, 255, 0.26)` | `rgba(77, 107, 254, 0.3)` | host's own | | | `--dsh-claude-hover-bg` | `hover-wash` | `rgba(6, 6, 6, 0.08)` | `rgba(255, 255, 255, 0.08)` | `rgba(4, 4, 6, 0.08)` | `rgba(251, 255, 255, 0.08)` | `var(--dsw-alias-interactive-bg-hover)` | hover wash | | `--dsh-claude-field-ring` | literal | `#2a78d6` | `#2a78d6` | `#2a78d6` | `#2a78d6` | `var(--dsw-alias-brand-primary)` | the ring around a field the reader is renaming in: the session title's own editor, drawn from the selection blue | | `--dsw-focus-ring-color` | literal | `rgba(20, 20, 19, 0.45)` | `rgba(250, 249, 245, 0.60)` | `rgba(20, 20, 19, 0.45)` | `rgba(250, 249, 245, 0.60)` | host's own | the keyboard focus ring: the host's own `:focus-visible` rules paint it, and the skin's fallback ring reads it | | `--dsh-claude-inline-code-bg` | `code-wash` | `rgba(6, 6, 6, 0.05)` | `rgba(255, 255, 255, 0.05)` | `rgba(4, 4, 6, 0.05)` | `rgba(251, 255, 255, 0.05)` | `var(--dsw-alias-markdown-inline-code)` | inline code fill | | `--dsh-claude-inline-code-fg` | card `code` | `#161616` | `#fdfdfd` | `#0f1115` | `#eef1f8` | `var(--dsw-alias-label-primary)` | inline code ink | | `--dsh-claude-link` | `link` | `#184f95` | `#8ab4f8` | `#3b56d9` | `#8fa4ff` | `var(--dsw-alias-link)` | markdown links | | `--dsh-claude-link-underline` | `link-underline` | `rgba(24, 79, 149, 0.6)` | `rgba(138, 180, 248, 0.6)` | `rgba(59, 86, 217, 0.6)` | `rgba(143, 164, 255, 0.6)` | `color-mix(in srgb, var(--dsw-alias-link) 60%, transparent)` | markdown link underline | | `--dsw-alias-markdown-code-block` | `code-block` | `#ffffff` | `#212121` | `#ffffff` | `#1b1e26` | host's own | | | `--dsw-alias-markdown-code-block-banner` | `overlay` | `#ffffff` | `#282828` | `#ffffff` | `#22252d` | host's own | | | `--dsw-alias-interactive-bg-hover` | `--dsh-claude-hover-bg` | `var(--dsh-claude-hover-bg)` | `var(--dsh-claude-hover-bg)` | `var(--dsh-claude-hover-bg)` | `var(--dsh-claude-hover-bg)` | host's own | | | `--dsw-alias-interactive-bg-hover-solid` | `hover-plate` | `#f4f4f4` | `#323232` | `#f0f1f4` | `#2b2f37` | host's own | the host's solid hover chip | | `--dsw-alias-label-primary` | `ink` | `#161616` | `#fdfdfd` | `#131416` | `#f4f9ff` | host's own | | | `--dsw-alias-label-primary-bluish` | `ink` | `#161616` | `#fdfdfd` | `#131416` | `#f4f9ff` | host's own | | | `--dsw-alias-label-secondary` | `ink-2` | `#70686a` | `#beb6b8` | `#656871` | `#aeb5c4` | host's own | | | `--dsw-alias-label-tertiary` | `ink-3` | `#91888a` | `#9c9395` | `#848891` | `#8a92a3` | host's own | | | `--dsw-alias-label-caption` | `ink-4` | `#b2acad` | `#776f71` | `#a8abb2` | `#676e7d` | host's own | | | `--dsw-alias-label-shimmer` | `shimmer` | `#161616` | `#fdfdfd` | `#131416` | `#f4f9ff` | host's own | the highlight band's tint over running words: the host's own rows and the skin's band (shared/sweep-band.ts) alike | | `--dsw-alias-label-deep-diving-shimmer` | `shimmer` | `#161616` | `#fdfdfd` | `#131416` | `#f4f9ff` | host's own | the band's tint inside the running row, which the host points --dsw-alias-label-shimmer at; under Claude it is the same warm band as everywhere else | | `--dsw-alias-state-business-primary` | `accent` | `#d97757` | `#d97757` | `#4d6bfe` | `#4d6bfe` | host's own | | | `--dsw-alias-state-business-tertiary` | `accent-tint` | `#f6ded7` | `#48261b` | `#dce4f7` | `#252f4c` | host's own | | | `--dsw-alias-link` | `ui-link` | `#bf6446` | `#eaa088` | `#3e57e0` | `#7594fd` | host's own | text link at rest | | `--dsw-alias-shadow-lv2` | literal | `0 4px 18px rgba(20, 20, 19, 0.10), 0 1px 3px rgba(20, 20, 19, 0.05)` | `0 4px 16px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.14)` | `0 4px 18px rgba(20, 20, 19, 0.10), 0 1px 3px rgba(20, 20, 19, 0.05)` | `0 4px 16px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.14)` | host's own | | | `--dsw-specific-input-major` | `inset` | `#ffffff` | `#141414` | `#ffffff` | `#0e1117` | host's own | | | `--dsw-specific-selector` | `selector` | `#fefefe` | `#323232` | `#fafbfe` | `#2b2f37` | host's own | | | `--dsw-specific-sidebar-fill` | card `sidebar` | `#fafafa` | `#1f1f1f` | `#f7f9ff` | `#13161d` | host's own | | | `--dsw-alias-brand-primary-new-colorprimary-new-color` | `accent` | `#d97757` | `#d97757` | `#4d6bfe` | `#4d6bfe` | host's own | | | `--dsh-claude-canvas` | `canvas` | `#ffffff` | `#191919` | `#fbfcff` | `#13161d` | `var(--dsw-alias-bg-base)` | the page canvas | | `--dsh-claude-sidebar-canvas` | card `sidebar` | `#fafafa` | `#1f1f1f` | `#f7f9ff` | `#13161d` | `var(--dsw-specific-sidebar-fill)` | the sidebar | | `--dsh-claude-card` | `card` | `#ffffff` | `#232323` | `#fbfcff` | `#1d2028` | `var(--dsw-alias-bg-overlay)` | cards on the canvas in light and raised in dark: the account popover, the search palette | | `--dsh-claude-inverse-fill` | `ink` | `#161616` | `#fdfdfd` | `#131416` | `#f4f9ff` | `var(--dsw-alias-interactive-bg-hover)` | the model picker's group label fill: an inverted chip under Claude, a hover-plate chip under the host | | `--dsh-claude-inverse-ink` | `inverse-ink` | `#ffffff` | `#171717` | `#feffff` | `#11141a` | `var(--dsw-alias-label-primary)` | the model picker's group label ink | | `--dsh-claude-table-head` | `table-head` | `#f4f4f4` | `#282828` | `#f0f1f4` | `#22252d` | `var(--dsw-alias-bg-layer-2)` | table header row | | `--dsh-claude-ripple-ink` | `ink-3` | `#91888a` | `#9c9395` | `#848891` | `#8a92a3` | `var(--dsw-alias-label-secondary, #8a8a85)` | the new-conversation page's glyph field, and the wake a ripple leaves in it | | `--dsh-claude-data-ink` | literal | `#3b6ecf` | `#3b6ecf` | `#3b6ecf` | `#3b6ecf` | `var(--dsw-alias-link, #3b6ecf)` | the home page's data blue: the deepest step of the model chart's ramp and the ink the heat grid's levels are mixed from | | `--dsh-claude-ripple-wave` | literal | `#3b6ef0` | `#7fa6ff` | `#3b6ef0` | `#7fa6ff` | `var(--dsw-alias-link, #3b6ef0)` | the front of a ripple in the new-conversation page's glyph field | | `--dsh-claude-scrollbar` | `scrollbar` | `rgba(213, 213, 213, 0.9)` | `rgba(62, 62, 62, 0.9)` | `rgba(209, 210, 213, 0.9)` | `rgba(55, 59, 64, 0.9)` | `var(--dsw-alias-border-l2)` | scrollbar thumb | | `--dsh-claude-scrollbar-hover` | `scrollbar-hover` | `rgba(138, 138, 138, 0.8)` | `rgba(113, 113, 113, 0.9)` | `rgba(134, 135, 138, 0.8)` | `rgba(105, 109, 115, 0.9)` | `var(--dsw-alias-label-caption)` | scrollbar thumb under the pointer | | `--dsh-claude-link-hover` | `ui-link-hover` | `#a35035` | `#ffbea6` | `#2e41c0` | `#91b1ff` | `var(--dsw-alias-link)` | text link under the pointer | | `--dsh-claude-logo-ink` | card `logoInk` | `#161616` | `#fdfdfd` | `#4d6bfe` | `#4d6bfe` | `var(--dsw-alias-label-primary)` | sidebar brand art: the whale and the wordmark | | `--dsw-specific-menu` | `overlay` | `#ffffff` | — | `#ffffff` | — | host's own | | | `--dsw-specific-bubble` | card `bubble` | `#f4f2f3` | `rgba(255, 255, 255, 0.06)` | `#ecf0ff` | `#262b36` | host's own | user message bubble | | `--dsh-claude-chip` | `chip` | `#f9f9f9` | — | `#f5f6f9` | — | `var(--dsw-specific-selector)` | segmented control and switch at rest | | `--dsh-claude-meter-peak` | literal | `#a4621d` | `#e2a45f` | `#a4621d` | `#e2a45f` | `var(--dsw-alias-state-warn-label)` | the peak rate badge: the expensive end of a provider's own billing clock | | `--dsh-claude-meter-valley` | literal | `#3d7550` | `#8cbf96` | `#3d7550` | `#8cbf96` | `var(--dsw-alias-state-success-primary)` | the off-peak rate badge, and the dated promotions priced below it | ## Two palettes · 两套配色归属 The Colours and Typefaces rows each choose who paints: **Claude** (the default) or **Follow the host**. Every rule that writes a host token carries the Claude gate in its selector, and so does every rule that paints the skin's canvas onto the host's frame (``, `
`, `#root`, the sidebar and conversation columns); the build refuses an ungated one. Under "follow the host" those rules drop out: the host's own tokens stand, or another theme plugin's, such as a wallpaper plugin that clears the canvas and turns the overlays to glass. The skin's own surfaces read only its private tokens and host tokens. Under the host palette each private token is an alias of a host token (the token table's Host alias column), so those surfaces follow whoever paints the host's. A new private colour token gets its tone and its host alias in `packages/client/src/theme/tokens.json`; the build refuses a private token without an alias. Cards take the host's overlay layer rather than its canvas: a plugin that clears the canvas still gives its overlays a readable fill, and the shared popover card blurs what lies behind it so its rows stay readable over a picture. Under the host typeface the skin's own faces fall back to the host's: `--dsh-claude-font-serif`, `-prose` and `-brand` to `--dsw-font-family` (the host carries no serif), `--dsh-claude-font-code` to `--ds-font-family-code`. The account-hold page keeps its own colours, reproducing a Claude screen. The reasoning slider and its trigger take the theme accent: the dot matrix and knob glow read `--dsh-claude-apex*`, and the level ramp reads `--dsh-claude-effort-*`. Translucent neutral hover tints and the usage panel's data colours stay literal; they read on any canvas. Every other colour that paints a surface, a text, a border or an accent reads a token. **The effort trigger's tone.** While the Composer tab's effort-accent row is on, the effort level's text in the composer's seat box and the card's level name take their colour from the rung in force; the caption and the scale ends keep their own caption inks. Every thinking rung has one place on a single ramp between two ends mixed from the theme accent: the low end toward the raised surface (less saturated, lower contrast), the high end toward the label ink (more contrast), and the rung's index over the ladder's span places it. The hue starts at the theme accent and travels slightly toward red as the rung climbs. The switch-off level and the model's own default level have no strength the catalog names, so both keep the resting ink (`--dsw-alias-label-secondary`). The row off leaves the resting ink on every rung. **The composer's seat.** The model's name and the effort level share one box in the row, the way the host's own seat carries both texts: the box is the row's one item, it stands against the row's right end and grows leftward, and both of its cards hang from that right end. The box paints the hover plate — the two texts are two hit areas of one control, not two chips side by side — and the text under the pointer takes the stronger ink, except a level wearing the accent ramp, which keeps its rung and lets the plate answer the pointer. Each text is as wide as the words it shows, so a level's name changing width moves the box's left end and nothing else. ## Windows titlebar · 桌面顶栏 On the Windows desktop the caption row has no colour of its own: the sidebar and the conversation run to the window's top edge, each in its own fill, and the sidebar's hairline runs with them from the top edge to the bottom. The conversation column and the fullscreen file panel lose the host's top-left corner, so the hairline meets them straight. The three window buttons sit on the page itself: no block of their own behind them, and they dim with a modal's mask like everything else. Their hover plate is the system's, a tint of the button glyph's colour. ## Right sidebar · 右侧栏 A panel docked in the right column keeps the host's own fill: it stands flush with the column on every side, the host's own border between the column and the page, and no skin hairline, radius or elevation. A panel pulled out of the sidebar becomes the host's own floating window and keeps that window's frame; a split column gives each pane the same flush fill, with the dock's own resting line on the seam between them. **The start page.** The host centers its stack of entries in a box that never grows, so a list taller than the pane was clipped at both ends, its first rows out of reach. The stack scrolls: the first entry's top margin and the last entry's bottom margin take the free space while the stack fits, and fall to zero once it does not, so the list starts at the top and scrolls to its end. ## Typography - **Serif display** — headings / editorial statements (`--dsh-claude-font-serif`). - **Sans UI** — chrome, body (`--dsw-font-family`). - **Mono** — code, technical labels (`--dsh-claude-font-code`). ## Shapes - Radius: a ladder of 4 / 8 / 16 px, a 12px popover card with 6px rows, and pills for CTAs. A shape copied from a host control keeps that control's own geometry rather than the ladder, and says so in the stylesheet beside its feature. - Borders: 1px, and in dark not a colour but a highlight. The three border tokens are one translucent white at three strengths, so a card, a panel, a code block or a keycap reads as a chamfer catching the top light over whatever fill it sits on rather than as a contour whose colour has to be re-picked per surface. The light rungs stay the warm opaque ladder. - Separator: `--dsh-claude-divider`, a rung below the lightest border, drawn at 0.5px on every line that only separates — the sidebar's foot area, the search panel's foot, the context stats, the settings rows, a menu's separators, the file block's IN/OUT line and the transcript's `hr`. The line belongs to the layout rather than to a surface's opening animation, so one thickness covers every separator a card holds. A card, a field and a popover keep their 1px border in l1, so an outline always reads heavier than a separator inside it. - Spacing: 4 px rhythm. - Bottom edge: the transcript never fades behind the composer by shadow. The composer seat paints an opaque `var(--dsw-alias-bg-base)` bar across its own box (so the bar's height *is* the composer's height) and one `--dsh-composer-fade-h` gradient band above it, running to transparent. The same token is the transcript's bottom clearance, so the last turn rests exactly at the band's top edge. Never re-add background-coloured halo shadows to the card to hide the transcript: they have to be restated in every light / dark / focus / attachment rule, and every rule that resets the card's `box-shadow` inherits the job. - Focus: the composer field lights up — its hairline takes the input box's own shadow colour with a halo in the same tone and a deeper drop shadow; dark inverts the face, so the hairline and halo become bright ivory rather than a black edge on a near-black canvas. The footer tray below follows the same hairline so the two tiers stay one outline. Accent is never used for focus strokes. Every other control's keyboard ring is the host's own `:focus-visible` rule painting `--dsw-focus-ring-color`, which the skin states as the same ink; the skin's ring is a `:where()` fallback of no weight, so the rule of whichever layer owns the control — this skin's, the host's, or another plugin's `outline: none` — always wins. ## Motion Every duration, curve, distance, blur and scale the skin uses is a token here, and a stylesheet or a Web Animations call reads the token rather than writing the value (D9). One value per role, so a faster feedback or a calmer swap is one edit; the build refuses a time or a curve written anywhere else. - Feedback: a control answering hover, press or focus. - Reveal: content entering the chat area at the host's own pace. - Surface: an entrance — a menu, a popover, a panel, a dialog, a toast. - Exit: a dismissal, deliberately quicker than the entrance. - Travel: something moving along its own track (a fold's door, the reasoning slider). - The swap, its gap and the roll of a figure's digits come from the transitions this skin ports; `docs/THIRD_PARTY_NOTICES.md` names each one. - Loop periods are separate from durations: a sweep, a pulse, a blink, a breath. - Open: a menu growing out of its trigger, a dialog out of the centre, a settings page sliding in — the transitions.dev surfaces, settling on their own curve and closing on the exit duration. - The presets these tokens feed live in `packages/client/src/shared/motion.css`: a surface arriving or leaving, a menu growing out of its trigger, a plain fade, one half of a wording swap, the pulse a placeholder and the sidebar's ongoing light share, and the two paints the waiting line's words can wear (a highlight band and a colour wash). A feature that needs its own effect reads the tokens and keeps its own keyframes. The table below is generated from `packages/client/src/theme/tokens.json` by `npm run build`; edit the JSON, not the table. | Token | Value | Use | |---|---|---| | `--dsh-claude-duration-instant` | `80ms` | the composer caret following the insertion point | | `--dsh-claude-duration-feedback` | `120ms` | a control answering hover, press or focus: its colour, background, border, shadow, or a small turn or slide of its own parts | | `--dsh-claude-duration-reveal` | `120ms` | content entering the chat area: an opened row's body, and each streamed piece of the answer | | `--dsh-claude-duration-enter` | `320ms` | one message the reader sends growing into its row, the same growth on the Motion tab's card, and a colour disc lifting out of its row and springing back | | `--dsh-claude-duration-surface` | `150ms` | a floating surface or a group of controls appearing: popover cards, menus, panels, the search dialog, a toast, a strip fading in, a hint over what it names | | `--dsh-claude-duration-exit` | `120ms` | a surface leaving on the reader's dismissal: a menu or a dialog closing, the overtime badge leaving | | `--dsh-claude-duration-hint-exit` | `50ms` | a hint leaving the moment the pointer leaves what it names | | `--dsh-claude-duration-hint-delay` | `80ms` | how long the pointer rests on something before its hint starts to appear | | `--dsh-claude-duration-open` | `250ms` | a surface growing out of its anchor: a menu out of its trigger, a full-window dialog out of the centre, a settings page sliding in, the overtime badge sliding onto its line | | `--dsh-claude-duration-swap` | `150ms` | each half of a wording swap: the old words leaving, the new ones arriving | | `--dsh-claude-duration-swap-gap` | `50ms` | the offset the words arriving start after the words leaving, so the two halves of a wording swap travel together | | `--dsh-claude-duration-clear-delay` | `50ms` | how long after a field empties the streak under its cleared words starts to rise | | `--dsh-claude-duration-clear-text` | `400ms` | the two halves of clearing a field: the words leaving it, and the placeholder taking their place | | `--dsh-claude-duration-clear` | `1000ms` | one whole clear: the words leaving, the placeholder arriving, and the streak's rise, peak and fall | | `--dsh-claude-duration-roll` | `160ms` | a figure's characters rolling over to new digits | | `--dsh-claude-duration-move` | `200ms` | something travelling along its own track: a fold's door, the reasoning slider's knob and fill, a hint moving from one colour disc to the next | | `--dsh-claude-duration-count` | `220ms` | a session number counting to its new reading | | `--dsh-claude-duration-slide` | `240ms` | the segmented controls' sliding highlight, a sidebar icon's quarter turn, the wording box taking the width of the words arriving, and streamed content sliding up from the bottom edge | | `--dsh-claude-duration-fade` | `300ms` | a slow cross-fade: the theme flip, the titlebar tabs' hover reveal, the reasoning knob's glow, a placeholder handing its place to what it stood for | | `--dsh-claude-duration-bounce` | `350ms` | a switch's knob travelling across with its double bounce | | `--dsh-claude-duration-flight` | `400ms` | the sent message flying from the composer to its row | | `--dsh-claude-duration-step` | `500ms` | one step of the reasoning window's text rising two lines | | `--dsh-claude-duration-pop` | `500ms` | something popping into place: the overtime badge's own pill | | `--dsh-claude-duration-rise` | `500ms` | a line of the home greeting rising into place | | `--dsh-claude-duration-stagger` | `70ms` | how far behind the piece before it a staggered piece starts: the greeting's second line | | `--dsh-claude-period-dot-orbit` | `2400ms` | one turn of the waiting line's loader dots that swap corners: four quarter-turns of the two pairs, which leaves every dot where it started | | `--dsh-claude-period-dot-rotate` | `10s` | how long one of the waiting line's loader motions stands before another takes its place, the next one picked so a motion never follows itself | | `--dsh-claude-duration-dot-morph` | `480ms` | how long a loader dot takes, once the motion it leaves has it standing still, to travel from that pose into the path of the motion taking over | | `--dsh-claude-duration-reduced` | `0.01ms` | every skin animation and transition under the Reduced animation choice (D40) | | `--dsh-claude-period-host-sweep` | `1500ms` | one pass of the highlight band across the waiting line's words and a running process group's header | | `--dsh-claude-duration-host-sweep-delay` | `300ms` | how long the highlight band waits after its words appear before its first pass | | `--dsh-claude-period-preview-swap` | `4000ms` | one turn of the settings page's waiting-line preview: one wording standing, leaving, and standing again | | `--dsh-claude-period-preview-reveal` | `4000ms` | one turn of the settings page's new-text fade preview: the sample arriving character by character, then standing before the next wipe | | `--dsh-claude-period-preview-caret` | `4000ms` | one turn of the settings page's caret preview: the sample line typed out, then standing whole before the next turn | | `--dsh-claude-period-preview-enter` | `2600ms` | one turn of the Motion tab's message-entrance card: one message arriving, standing, and giving way to the next | | `--dsh-claude-period-wash-hue` | `4000ms` | one turn of the hue rotation the waiting line's colour wash runs through | | `--dsh-claude-period-wash-drift` | `5000ms` | one orbit of the colour washes around the waiting line's glyph box | | `--dsh-claude-period-pulse` | `1600ms` | a placeholder breathing while its numbers are computed | | `--dsh-claude-period-flash` | `1450ms` | one flash of the reasoning slider's top-rung matrix | | `--dsh-claude-period-flow` | `4000ms` | one pass of the reasoning slider's top-rung particle flow | | `--dsh-claude-period-blink` | `1000ms` | one blink of the composer caret | | `--dsh-claude-period-breathe` | `2400ms` | one breath of the sidebar's ongoing-conversation light | | `--dsh-claude-period-spin` | `850ms` | one turn of the sidebar's ongoing-conversation ring | | `--dsh-claude-period-landed` | `1400ms` | the mark on the turn the conversation navigator landed on | | `--dsh-claude-period-hold` | `840ms` | how long the reasoning window's text stands between two steps | | `--dsh-claude-period-dot-bounce` | `500ms` | one bounce of the waiting line's loader dots under the Claude brand, and the period each dot's own start is measured against | | `--dsh-claude-period-dot-line` | `1250ms` | one round trip of the waiting line's loader dots along their own lines, the motion the dots take while the turn is not thinking | | `--dsh-claude-ease-standard` | `ease` | feedback and surfaces: the default curve | | `--dsh-claude-ease-out` | `ease-out` | something arriving and settling: a fold's door, content entering the chat area, the landing mark, a hint appearing and leaving | | `--dsh-claude-ease-enter` | `cubic-bezier(0.34, 1.3, 0.64, 1)` | a message the reader sends growing into its row: the slight overshoot as it takes its full size | | `--dsh-claude-ease-in-out` | `ease-in-out` | something that leaves and returns, or swaps in place: wording swaps, pulses, flashes | | `--dsh-claude-ease-glide` | `cubic-bezier(0.22, 0.61, 0.36, 1)` | travel along a track: the sliding highlight, the reasoning slider, the search dialog's entrance, the reasoning window's step, the wording box's width, the stream's slide | | `--dsh-claude-ease-roll` | `cubic-bezier(0.4, 0, 0.2, 1)` | digits rolling over: the file rows' tail and the process group header's counts | | `--dsh-claude-ease-spring` | `cubic-bezier(0.34, 1.45, 0.64, 1)` | a small overshoot as something lands: the overtime badge popping in, a switch's knob bouncing past its end | | `--dsh-claude-ease-recoil` | `cubic-bezier(0.34, 3.85, 0.64, 1)` | a lifted colour disc springing back into its row once the pointer leaves, overshooting well past its place before it rests | | `--dsh-claude-ease-settle` | `cubic-bezier(0.22, 1, 0.36, 1)` | a surface or a page settling into place out of its anchor: menus and dialogs opening and closing, a settings page sliding, the overtime badge sliding onto its line, the greeting's lines rising, a hint travelling between colour discs, a colour disc lifting | | `--dsh-claude-ease-host-sweep` | `steps(48, end)` | the band across the waiting line's words and a running process group's header | | `--dsh-claude-ease-harmonic` | `cubic-bezier(0.37, 0, 0.63, 1)` | a loader dot walking its line from one end to the other, its size between an end and the middle, and its travel from one motion into the next: a sine's simple harmonic, at rest at either end and fastest halfway | | `--dsh-claude-ease-damped-out` | `cubic-bezier(0.33, 1, 0.68, 1)` | a loader dot's fade as the loader opens, for a motion that does most of its change early: a walking or swapping dot coming in | | `--dsh-claude-ease-damped-in` | `cubic-bezier(0.32, 0, 0.67, 0)` | a loader dot's fade as the loader opens, for a motion that does most of its change late: a bouncing dot coming in | | `--dsh-claude-distance-nudge` | `4px` | the short offset a surface rises from, a small row's wording swap travels, and the colour disc under the pointer lifts | | `--dsh-claude-distance-travel` | `8px` | the waiting line's wording swap, a settings page's slide, the overtime badge's sideways slide, and both directions of the waiting line's loader dots: the lift off their floor and the reach along their own lines | | `--dsh-claude-distance-rise` | `12px` | how far below its place a line of the home greeting starts, and the overtime badge's upward slide | | `--dsh-claude-blur-soft` | `2px` | the blur words and badges pass through while they swap or pop in, and a placeholder passes through as it hands its place over | | `--dsh-claude-blur-enter` | `3px` | the blur a settings page or a line of the home greeting passes through as it slides or rises in | | `--dsh-claude-scale-surface` | `0.98` | the scale a surface grows from as it rises, and a hint grows from as it appears | | `--dsh-claude-scale-dropdown` | `0.97` | the scale a menu grows from out of its trigger | | `--dsh-claude-scale-dropdown-exit` | `0.99` | the scale a menu shrinks to while it fades on closing | | `--dsh-claude-scale-lift` | `1.05` | the scale the colour disc under the pointer grows to as it lifts | | `--dsh-claude-scale-lift-falloff` | `0.45` | the share of its neighbour's lift a colour disc keeps, one disc further from the pointer | | `--dsh-claude-scale-dialog` | `0.96` | the scale a full-window dialog grows from and returns to | | `--dsh-claude-scale-dot-mid` | `0.55` | how small a loader dot is while it crosses the middle of its line; at either end it is the dot's own size, so the line motion never draws a dot larger than the bouncing one | | `--dsh-claude-duration-resize` | `300ms` | a surface taking a new size: the model picker's second level following the list a query leaves | | `--dsh-claude-timing-resize` | `var(--dsh-claude-duration-resize) var(--dsh-claude-ease-settle)` | shorthand: a size change, written after the property in a transition | | `--dsh-claude-timing-feedback` | `var(--dsh-claude-duration-feedback) var(--dsh-claude-ease-standard)` | shorthand: a control's state change, written after the property in a transition | | `--dsh-claude-timing-surface` | `var(--dsh-claude-duration-surface) var(--dsh-claude-ease-standard)` | shorthand: a surface appearing, written after the property or the keyframes' name | ## Layers A z-index the skin writes is one of two kinds (D9). Inside one box, siblings are ordered with a literal from -1 to 9: a fill painted under its own content, a label over its sliding pill, the composer's rows over one another. Anything that has to clear what the host draws around it — the composer's own controls, the window's title row, a floating card, a surface over the whole window — stands on a layer token, and the build refuses any other number. The table below is generated from `packages/client/src/theme/tokens.json` by `npm run build`; edit the JSON, not the table. | Token | Value | Use | |---|---|---| | `--dsh-claude-layer-control` | `10` | a control kept over the composer card's own stacked rows: the send and stop buttons, the reasoning picker's trigger | | `--dsh-claude-layer-picker` | `20` | a picker that hangs its card from inside the composer: the permission picker, whose card has to stand over the controls beside it | | `--dsh-claude-layer-chrome` | `30` | the window's title row the skin draws into: the header band's title and actions, the titlebar's view tabs, over the host's own header | | `--dsh-claude-layer-peek` | `40` | the sidebar opened over the page while the pointer rests on its toggle: over the main window's title row and its own controls, under the floating cards and menus its rows open | | `--dsh-claude-layer-float` | `99999` | a floating card or menu over everything the host draws: the popover cards, the hero menu | | `--dsh-claude-layer-takeover` | `2147483000` | a surface that covers the whole window: the account-hold page, the sent message's stand-in in flight | ## Text selection Selection is the one surface that leaves the palette on purpose — it is a transient gesture, not part of the interface, so it uses the platform's two solid paints and looks the same on both canvases. Both are solid (never translucent) and `!important`, so they override whatever colour the text underneath carries — links, inline code, syntax tokens. CSS cannot read window focus: Chromium reaches the inactive paint through its own internal `-internal-inactive-selection-*` properties, which a stylesheet cannot address. `packages/client/src/features/composer/selection-mirror.ts` therefore mirrors `document.hasFocus()` onto `data-dsh-window-blur` and the stylesheet switches on that attribute; the selection itself survives the blur. The window's chrome is not text. The sidebar column and the conversation header take `user-select: none`: a press on a row or on the session title is a gesture at the window — on the Windows desktop the caption row is the shell's own drag region — and the drag that follows must not paint a selection across the labels it passes. The transcript, the composer's draft and a text field inside the chrome keep their own selection, which Chromium grants an editing host under a locked ancestor. The session title is the session's name, and a press on it renames the session. The pointer over the title's last crumb raises a plate under the words — the host's own crumb already carries the padding and the radius — with a tip under the segment naming the press, in the label ink on the canvas tone the usage panel's day tip uses. The segment carries the tip rather than the words because the host clips a crumb's overflow for its ellipsis. The press opens a field over the words' own line, in the selection's blue and in the page's own space so the reader's zoom keeps it on the title; the field hugs the name the way Claude's own field does rather than laying a card over it. A rename the host refuses keeps the field, ringed in the host's error tone. ## Markdown material A quote is a **container**, not a link: it keeps the prose face on a neutral bar and wash, and whatever sits inside it keeps its own material — links stay blue, inline-code chips stay warm red, file mentions stay link-blue. Never paint the quote itself with the link colour: the colour inherits into the block's inline code and mentions, which is exactly the leak that made every path inside a quote read as a link. **Inline file mentions are links, not code.** The host resolves a file path inside an inline code span to a button (`.fileMention`, hashed — match it with `[class*="_fileMention"]`) and paints it with its link alias. The generic inline-code chip rule is more specific than that class, so a mention inherits the chip's warm red unless a rule names it. A mention takes the link blue, the link's weight and the link's underline — solid in the link tone at rest, solid and fully opaque on hover, same thickness and offset as an anchor; the chip itself is left alone, with the same fill, hairline, radius and padding as any other inline code, and plain inline code keeps its warm text. Note the hover rule must set only `text-decoration-color`: the `text-decoration` shorthand resets `text-decoration-thickness` to `auto` and thins the line mid-hover. ## Inline code The chip hugs its glyphs. The host builds it as an `inline-flex` box that inherits the prose line box, which left empty wash above and below the text; the skin tightens the code's own line box so the wash sits on the text, stopping short of the value that starts clipping descenders. The fill, hairline, radius and size stay the host's. ## Popovers · 多选一弹层 Every single-choice popover in the skin — the permission menu, the model picker, the account drawer, and the host's own menu primitive under the hero row's workspace and preset pickers — starts from one recipe. New popovers take it rather than inventing a card. The shell, its rows and the separator every card shares are written once in `shared/popover.css`; a card's heading and footer are its own. Two rules hold across all of them, both owned by `shared/popover.ts`. A pointer opens a card only after a short dwell — long enough that crossing a trigger on the way somewhere else unfolds nothing — and the card closes a short grace after the pointer leaves. A card that keeps its own numbers writes them where they are used, with the reason beside them: the model picker's levels close later, because the pointer has to cross level 1 to reach level 2. And **only one card is on screen at a time**: opening one closes the others through the shared registry, so opening the model picker folds the permission menu, the account drawer and the host's hero menu. Every card the skin places stays inside the window: the shared placement holds the resolved position inside the window's own pixels, so a card taller than the room beside its trigger begins at the window's top edge rather than drawing its lower rows past the bottom of the page. The context meter's popover is the host's own panel (it portals it, places it, and dismisses it), so the skin drives its trigger on hover and paints nothing of the shell itself. What it carries is described under "Context popover" below. **Host surfaces.** The host's dropdown menus are one shared primitive (primitives' `Menu`), and its two class-name families hash in opposite directions: the primitive ships inside the web shell as `_