diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index fa225be3f1..44123a6fa3 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -19,11 +19,10 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including 2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter. 3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text. 4. **Internal package source changes that enter the CLI bundle must manually list the CLI — when they get a changeset at all.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output and is user-perceivable, list `@moonshot-ai/kimi-code`. See rule 6 for when to skip the changeset entirely. - - **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. 6. **Skip changes users cannot perceive — write no changeset at all.** The CLI changelog is user-facing; a changeset is a changelog entry, not a shipping gate. Internal changes merged to `main` still ship in the next release triggered by any user-facing changeset, so skipping the changeset loses nothing. Do not write changesets for: - `agent-core-v2` internal architecture: new services, refactors, config-persistence or journal/wire mechanisms. - - `kap-server` WebSocket / REST protocol changes consumed only by the bundled web UI, kimi-inspect, or other dev tooling (new endpoints, subscribe protocols, stream baselines). A web-facing feature they back gets its own `web:` entry instead. + - `kap-server` WebSocket / REST protocol changes consumed only by the bundled web UI, kimi-inspect, or other dev tooling (new endpoints, subscribe protocols, stream baselines). - Behavior that only takes effect on the experimental engine (e.g. experimental `kimi -p`), unless it exposes documented user configuration such as a `config.toml` section or env vars that also work on a shipped surface (TUI or `kimi web`). - When unsure whether users can perceive a change, ask before writing. 7. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter. @@ -153,36 +152,6 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` -## Web app changes - -`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. - -- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`). -- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets. -- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. - -Web-only fix: - -```markdown ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Fix the chat not scrolling to the bottom after sending a message. -``` - -Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing): - -```markdown ---- -"@moonshot-ai/kimi-code": minor ---- - -web: Add the server-hosted web UI, including chat layout and session list behaviors. -``` - -Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.` - ## `@moonshot-ai/pi-tui` changes `@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui. @@ -233,5 +202,3 @@ Fix the transcript jumping to the top when scrolling up through history during s - The CLI wording mentions internal package names, class names, or PR numbers. - The entry includes real internal identifiers instead of neutral placeholders. - A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. -- A web app change entry is missing the `web: ` prefix. -- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value). diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index fad6e4c8b3..ac9ff16046 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -120,14 +120,6 @@ Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/ Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. -Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI: - -```markdown -- web:
-``` - -An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-onlyThis document defines the visual language and component specification for Kimi Web — design tokens, component primitives, the chat interface, theming, and style rules. All UI work is grounded in it: unified, restrained, token-driven, and themeable.
Every UI decision traces back to the following principles. Kimi Web is a local Agent tool for developers: quick scanning, long stretches of staring, often in the dark — the design serves the task, and is restrained, clinical, and density-first.
Collapse every visual decision into tokens. Color tokens keep the existing short names and fill out the semantics (lowering migration cost), while spacing, z-index, motion, and font-weight fill in the scales that are currently missing. Every token has: name, light value, dark value, and usage.
--<category>-<role>-<state>. For example --color-text-muted, --radius-md, --space-4. To reduce churn, the existing short names (--bg / --ink / --line / --blue …) are kept as compatibility aliases for one release cycle. Semantic-first, in three layers: background / text / border + accent + status colors. All colors are defined in light / dark pairs, with contrast ≥ 4.5:1.
:root and a dark override in the data-color-scheme blocks — for example --color-bg is #ffffff in light and #121212 in dark; --color-accent is the brand blue (#1783ff light / #1a88ff dark). The semantic status colors (success / warning / danger / info) are independent palettes, one set each for light / dark.| Token | Light | Dark | Usage |
|---|---|---|---|
| --color-bg | #ffffff | #121212 | Page background |
| --color-surface | #f5f5f5 | #1f1f1f | Panel / sidebar / card head |
| --color-surface-raised | #ffffff | #292929 | Raised card / dialog / input |
| --color-menu-bg | rgba(255,255,255,.95) | rgba(41,41,41,.95) | Floating menu panel — frosted glass over --p-menu-backdrop blur |
| --color-surface-overlay | #ffffff | rgba(255,255,255,.1) | Field-control fill on raised cards (selects, steppers) — top rung; light tops out at white (the level is carried by the border), dark steps one rung above raised. Floating layers stay at raised |
| --color-well | #f5f5f5 | #1f1f1f | Content well on the page (code blocks, tool-output panels, match/file lists, media thumbnails) — light reuses the sunken recess; dark lifts one rung ABOVE the page, because a true recess (#121212) vanishes into the page there |
| --color-surface-deep | #f5f5f5 | #0d0d0d | Deep chrome plane one step BELOW the page (panel headers, diff gutters) — dark drops under --color-bg so chrome framing stays darker than the content it frames |
| --color-text | rgba(0,0,0,.9) | rgba(255,255,255,.84) | Body text / headings |
| --color-text-strong | #000000 | #ffffff | Max foreground emphasis — menu-row label & icon on hover |
| --color-text-muted | rgba(0,0,0,.6) | rgba(255,255,255,.56) | Secondary text / placeholder |
| --color-line | rgba(0,0,0,.13) | rgba(255,255,255,.12) | Divider / card border |
| --color-subtle | rgba(0,0,0,.05) | rgba(255,255,255,.05) | Subtle hairline — tertiary separators below --color-line (diff-gutter column rules, quiet dividers inside wells) |
| --color-selected | rgba(0,0,0,.05) | rgba(255,255,255,.1) | Neutral selected fill (sidebar rows, list pickers) — translucent, never accent-tinted |
| --color-hover | rgba(0,0,0,.03) | rgba(255,255,255,.05) | Row hover wash — lighter than the selected fill (hover < selected); translucent, sits on any surface. The global hover rule: transparent-base controls overlay this f1 wash (hover never darkens — never sunken); filled controls use their own hover token (accent-hover, send-bg-hover) |
| --color-inline-code-bg | rgba(0,0,0,.03) | rgba(255,255,255,.1) | Inline-code chip fill — fills.f1 / fills.f2; dark lifts off any dark surface (sunken == bg there) |
| --color-media-alpha-bg-1 | ≈#858585 | ≈#76797e | Checkerboard square A of the <img> alpha canvas — color-mix of --color-bg/--color-text (52/48); applied via --media-alpha-canvas (16px period) |
| --color-media-alpha-bg-2 | ≈#6b6b6b | ≈#8c8f93 | Checkerboard square B (42/58) — both squares stay ≥3:1 against white and black; opaque images cover the canvas |
| --color-sidebar-bg | #f9fbfc | #0d0d0d | Sidebar surface — one step off --color-bg (just under white in light, one step BELOW the page in dark) so the session column reads as its own plane and never brighter than the reading surface |
| --color-scrim | rgba(0,0,0,.4) | rgba(0,0,0,.6) | Modal scrim — the dark veil behind dialogs/lightboxes (mask.base; legacy hardcoded overlays can migrate here) |
| --color-scrim-strong | rgba(0,0,0,.6) | rgba(0,0,0,.75) | Stronger scrim for full-screen media previews (mask.strong — the PhotoSwipe image preview backdrop) |
| --color-text-on-scrim | #ffffff | same | Text drawn on the scrim (captions over the media lightbox) |
| --color-accent | #1783ff | #1a88ff | Primary action / link / focus |
| --color-success | #0e7a38 | #3fb950 | Success / pass |
| --color-warning | #a9610a | #d29922 | Warning / pending |
| --color-danger | #c0392b | #f85149 | Danger / error / abort |
The palette is the production kimi.com palette (design tokens tokens.json): neutral-gray surfaces, an alpha-based label / fill / separator ramp (labels.* / fills.* / separator.s1), the KMBlue accent, and a true neutral dark ladder (#121212 → #1f1f1f → #292929; the deep chrome plane and sidebar derive one step below at #0d0d0d — the palette has nothing darker than primary).
The ONE deliberate exception is the status hues: success / warning / danger / done keep the app's own WCAG-tuned ramp (≥4.5:1 on the neutral surfaces) — the production status colours (positiveGreen #16c456, orange #ff9500, danger red #ff3849) are too bright against it. Diff add/del bands happen to coincide (both use the production 25% fills in light, 14% in dark).
The surface layers each have a role — choose by "field overlay / raised layer / content well / default flat layer / sunken layer / page background / deep chrome", and avoid treating --p-surface-raised as a universal background. In dark, elevation = lighter: floating layers sit above the content, content wells sit above the page, and chrome planes (sidebar, panel headers) sit below it — never the reverse. One consequence: on the page itself, never use --color-surface-sunken for a content carrier — it equals --color-bg in dark and the fill vanishes; use --color-well. Sunken stays correct INSIDE surface / raised cards, where it is a genuine recess. Field controls (selects, steppers) on a raised card use --color-surface-overlay, the top fill rung; floating layers keep --color-surface-raised — their elevation is shadow + hairline, not a lighter fill.
| Token | Light | Dark | Usage |
|---|---|---|---|
| --p-surface-overlay | #ffffff | #22272e | Field controls on raised cards — select, stepper (top fill rung; light = white) |
| --p-surface-raised | #ffffff | #1c2128 | Raised card / dialog / input (raised layer) |
| --p-well | #f3f5f8 | #13181e | Code block / tool output / list carrier directly on the page (content well — light: recessed, dark: one rung above the page) |
| --p-surface | #fafbfc | #13181e | Panel / sidebar / card head (default flat layer) |
| --p-surface-sunken | #f3f5f8 | #0d1117 | Recessed area INSIDE a surface / raised card — never a content carrier on the page (sunken layer) |
| --p-bg | #ffffff | #0d1117 | Page background |
| --p-surface-deep | #fafbfc | #0a0d12 | Panel header / diff gutter (deep chrome layer — below the page in dark) |
Three line tokens, three jobs: --color-line is the default structural separator, --color-subtle the tertiary separator that must stay quieter (diff-gutter column rules, quiet dividers inside wells), and --color-line-strong the edge of interactive controls (inputs, selects, secondary buttons). Width is one: 0.5px — every stroke is the same hairline, on static structural edges (card rims, plane seams, header dividers), interactive control rims and floating layers alike. Separation comes from luminance first — planes one rung apart already read as distinct in dark, so their shared edge stays a 0.5px hairline rather than a heavier border; same-rung neighbours (list rows, card head / body) are exactly where a hairline is required. In dark, drop shadows fade on near-black surfaces, so a floating layer's edge IS its hairline — never ship a shadow-only floating surface. (Legacy --line / --line2 alias --color-line / --color-subtle for one cycle; new work references the v2 names.)
All focusable controls (button, input, link, menu item, switch, checkbox) use the focus-ring token uniformly; do not hand-write a box-shadow focus ring.
| Token | Value | Usage |
|---|---|---|
| --p-focus-ring | 0 0 0 3px var(--p-accent-soft) | Default focus ring (link, menu item, switch, checkbox) |
| --p-focus-ring-strong | 0 0 0 3px var(--p-accent-soft), 0 0 0 1px var(--p-accent) | Strong focus ring (button, primary action) |
The text-selection color uses --p-selection uniformly (light rgba(23,131,255,.18) / dark rgba(88,166,255,.32)), applied by the global ::selection rule; do not set a separate highlight background.
All disabled controls use opacity:.5 + cursor:not-allowed uniformly; do not separately grey out or recolor.
Kimi Web uses two font tokens: --font-ui (UI and body, with Schibsted Grotesk for Latin and Noto Sans SC for Simplified Chinese) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.
Body and UI use self-hosted Schibsted Grotesk for Latin text and self-hosted Noto Sans SC Variable for Simplified Chinese. Platform fonts remain as fallbacks:
--font-ui: "Schibsted Grotesk Variable", "Helvetica Neue", Arial, + "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", + "Microsoft YaHei", + -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Ubuntu, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
Code, line numbers, diffs, and Bash commands use JetBrains Mono (a self-hosted variable font), falling back to the system monospace. Other tool labels and summaries use the UI font:
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", + ui-monospace, "SF Mono", Menlo, Consolas, monospace;
| Font | Source | Bundled | Usage |
|---|---|---|---|
| JetBrains Mono | @fontsource-variable/jetbrains-mono | ✓ self-hosted | monospace / code (--font-mono) |
| Schibsted Grotesk | prepare-fonts → web-ui/assets/fonts | ✓ generated + bundled | UI / body / display (--font-ui, --font-display), wght 400-900, normal + italic |
| Noto Sans SC | prepare-fonts → web-ui/assets/fonts | ✓ generated + bundled | Simplified Chinese UI / body, wght 100–900 |
| System UI / CJK fonts | operating system | — | late fallback for UI / body |
var(--font-ui) / var(--font-mono); do not hard-code font names like 'Schibsted Grotesk' / 'JetBrains Mono'.--font-ui (Schibsted Grotesk for Latin, Noto Sans SC for Simplified Chinese); code / monospace use --font-mono (JetBrains Mono).font-optical-sizing: auto is enabled globally.The user font-size preference is one of four named steps (small / medium / large / xlarge, Medium default) written to data-font-scale on <html>; the step name is persisted, never a px value. The step only moves --base-font; every size token derives additively (default + shift), and line heights are locked to integer px via round(size × ratio, 1px) — never a unitless ratio.
Two token groups share the shift but keep their own ratios: --ui-* for chrome (tight, 1.40–1.50) and --md-* for Markdown content + the composer (loose, 1.56–1.63; body is anchored to the UI body size — the spec's +2px offset was dropped as a product decision — while keeping its own looser line-height ratios). T0/T1 cap at 24/22px on the top steps (built into the tokens via min() — do not remove). Use the .text-ui-* / .text-md-* utility classes; legacy aliases --ui-font-size (→ --ui-b2), --content-font-size (→ --md-b1) and the whole 6-level --text-* ramp (xs→c1, sm→b2−1px, base→b2, lg→t2, xl→t1, 2xl→t0) keep older components on the ramp. Panel titles sit at the base step (--ui-b2); dropdown menu items sit one rung below (--text-sm = b2 − 1px) — both still follow the user's font scale.
The fixed product type tokens still define scale-independent defaults: transcript prose enables text-autospace: normal for mixed CJK and Latin text. Drop stray font-weight: 650 / 750; converge on 400 / 500 (regular / emphasis), with a dedicated 600 weight for sidebar section labels.
| Token | Value | Usage |
|---|---|---|
| --font-ui | "Schibsted Grotesk Variable", …, "Noto Sans SC Variable", … | UI & body (Schibsted Grotesk + Noto Sans SC) |
| --font-kbd | "Schibsted Grotesk Variable", system-ui, sans-serif | keyboard shortcut keycaps |
| --font-mono | JetBrains Mono… | code, Bash commands, line numbers, diffs |
| data-font-scale | small / medium / large / xlarge | user preference on <html>; sets --base-font (12–18px), Medium = 14px default |
| --ui-t0…--ui-c2 | default + --ui-shift, t0/t1 capped via min() | chrome type ramp (title / subtitle / body / caption); .text-ui-* classes |
| --md-h1…--md-b3 | default + --md-shift | Markdown ramp (headings / body / secondary / code); .text-md-* classes |
| --ui-font-size / --content-font-size | var(--ui-b2) / var(--md-b1) | legacy aliases kept on the ramp |
| --code-font-size | calc(var(--content-font-size) - 2px) | standalone code surfaces (diff view, file preview, tool cards) — one step below body, 12px @ Medium; prose-embedded code stays on the --md-* ramp |
| --text-xs / sm / base / lg / xl / 2xl | c1 / b2−1 / b2 / t2 / t1 / t0 | legacy ramp, aliased into the scale |
| --leading-tight/normal/prose/relaxed | 1.25 / 1.5 / 1.6 / 1.7 | headings / UI / chat prose / long text |
| --weight-regular/option-label/medium/ui-strong | 400 / 475 / 500 / 525 | body / settings labels / emphasis / compact UI emphasis |
| --weight-section-label | 600 | sidebar section labels |
Icons use three size tokens uniformly. The global .p-ic default is 16px (--p-ic-md); components pick as needed, and random pixel sizes are forbidden.
| Token | Value | Usage |
|---|---|---|
| --p-ic-sm | 14px | small button, badge, menu item, inline link icon |
| --p-ic-md | 16px | default (button, icon button, toolbar) |
| --p-ic-lg | 20px | Toast status icon, empty-state illustration |
Icons always come from the centralized registry lib/icons.ts: in templates use the <Icon name size /> component (components/ui/Icon.vue); for v-html contexts (such as a tool glyph) use iconSvg(name, size). Do not hand-write <svg> — the scripts/check-style.mjs icon-from-registry rule flags stray SVGs. Every glyph shares the 24×24 source grid and currentColor (colour follows text); size uses the three tokens below, and only icons imported in lib/icons.ts are bundled by unplugin-icons at build time. Three collections feed the registry, in this order of preference: ~icons/kimi/* — Kimi Design System icons (24×24 outlined, 1.8px stroke), local SVGs under src/icons/kimi/ registered as a custom collection in the Vite config, used whenever a Kimi glyph exists for the intent; ~icons/tabler/* — Tabler Icons (MIT), for the few gaps it uniquely covers (today: the right-panel toggle); and ~icons/ri/* — Remix Icon (Apache-2.0), for the remaining intents the Kimi set does not cover yet. A few glyphs are filed under their intent rather than the upstream asset name (see the lib/icons.ts header). When an icon is missing, prefer a glyph from the Kimi icon set: copy the SVG into src/icons/kimi/ (kebab-case name, monochrome currentColor) and register it — two static imports (component + ?raw string) plus one entry in ICONS; reach for Remix only when no Kimi glyph fits, and never draw paths in a component.
Do not use emoji as functional icons. The Kimi brand mark (the robot mascot logo) is a brand asset and is not part of this icon system.
A few special graphics are not in the registry; each has a dedicated component maintained in one place, and must not be copied by hand: <ContextRing :pct /> (the Composer context progress ring, data-driven), <AuthStateIcon kind /> (the success / expired / error colored illustrations in the login flow), <Spinner /> (loading state). Status dots (such as in the Provider list) always use CSS dots (border-radius:50%), not SVG. The scripts/check-style.mjs icon-from-registry rule exempts the above and the brand mark; all other hand-written <svg> is flagged.
A 4px base grid. All spacing, gaps, and padding inside and outside components come from this scale — no arbitrary pixels.
High-density navigation lists like the sidebar share one rhythm, all on the 4px grid: in-row vertical padding --space-1 (4px), no margin between rows (the hover pill provides the separation); section gap (between logo / search / action buttons / group title / list) uniformly --space-2 (8px); between groups --space-2; the brand header is slightly looser at the top (--space-3). When building similar lists, reuse this scale — do not hand-write 1/6/7/10px.
Merge the existing 14 values into the nearest of 7 scale steps. Rule: the component type determines the radius, not the author's feel. The Composer shell is the sole product-specific exception: its 32px radius pairs with superellipse(1.5) so the flatter curve stays visually concentric with its controls.
| Token | Value | Usage | Merged from |
|---|---|---|---|
| --radius-xs | 4px | small badge, inline tag | 2/3/4px → |
| --radius-sm | 6px | small button, icon button, menu item | 5/6px → |
| --radius-md | 8px | button, input, badge, card | 7/8/9px → |
| --radius-lg | 12px | menu, toast, bubble, floating card | 10/12px → |
| --radius-xl | 16px | container baseline: dialogs, settings cards, sheets, work panel | 13/16px → |
| --radius-2xl | 20px | workspace attachment card bottom (0 0 2xl 2xl) tucked under the composer | 18/20px → |
| --radius-composer | 32px | Composer shell, with --corner-shape-composer | product-specific |
| --radius-full | 999px | pill badge, avatar, send button | 999px / 50% |
Shadows express only "elevation", never decoration (no colored glow). z-index is unified into a scale, eradicating 9999-style one-upping.
| Z-index Token | Value | Usage |
|---|---|---|
| --z-base | 0 | normal flow |
| --z-sticky | 100 | sticky header / sidebar |
| --z-dropdown | 200 | dropdown menu / tooltip |
| --z-overlay | 300 | overlay / bottom Sheet |
| --z-modal | 400 | dialog — sibling overlays tie-break by DOM order, so the global confirm (ConfirmDialogHost) mounts on demand to always land last / on top |
| --z-modal-dropdown | 500 | menus / popovers that open above a modal dialog (teleported to <body>, e.g. the settings SecondaryModelPicker cascade) |
| --z-toast | 600 | toast |
| --z-max | 9999 | reserved: only this tier for extreme fallback |
| Token | Value | Usage |
|---|---|---|
| --ease-out | cubic-bezier(0.16, 1, 0.3, 1) | enter, hover, expand |
| --ease-in-out | cubic-bezier(0.4, 0, 0.2, 1) | panel width, layout changes |
| --duration-fast | 120ms | press, focus |
| --duration-base | 160ms | hover, show/hide |
| --duration-slow | 260ms | dialog, Sheet, layout |
| --duration-hover-intent | 250ms | hover-intent reveal gate (TOC rail) |
| --anim-rive-spin | 416.7ms | new-chat / folder-plus icon: plus spin on hover |
| --anim-leftbar | 533.3ms | sidebar toggle icon: arrow fly-in on hover |
| --anim-leftbar-shrink | 200ms | sidebar toggle icon: divider shrink on hover |
The --anim-* lengths are track timings ported verbatim from the designer's Rive exports, so they sit outside the --duration-* ramp on purpose — retiming the ramp must not distort them. Their interpolation stays linear because the easing is already baked into the dense keyframe stops; a token easing would double-apply. Three hover tracks use them today: the sidebar toggle shrinks its divider to half height while an arrow flies in and settles (the expand variant mirrors the track from the left), and the new-chat / folder-plus pluses do one bouncy spin. Each track is keyed to an id inside its own glyph (#bar-divider, #bar-arrow / #bar-arrow-expand, #p1, #af-p1) so every instance of the icon animates, and all revert on mouse-out. They still fall under the global reduced-motion switch below.
@media (prefers-reduced-motion: reduce), all animation and transition durations drop to about 0.001ms (effectively off), and the chat working indicator's mascot renders its static fallback instead of the Rive loop. Components should not check this individually; it is handled uniformly in the global styles. The switch clears durations, not transition-delay: a hover-intent gate (the conversation TOC's 250ms reveal) decides whether hidden content appears, and clearing it would make pointer fly-bys strobe content for reduced-motion users. Layout sizes and responsive breakpoints are tokenized too: sidebar width, content reading-column width, and two global breakpoints. Components should not hard-code pixels.
| Token | Value | Usage |
|---|---|---|
| --p-sidebar-w | 264px | left session sidebar width |
| --p-content-max | 760px | chat reading-column max width (regular chat prose) |
| --p-content-wide | 920px | wide content (settings / panel) |
| --p-table-max | 1040px | desktop wide-table max width (see §04) |
| --p-table-cell-max | 700px | max width of a single table column; longer cell content wraps (see §04) |
| --p-bp-sm | 640px | mobile / desktop boundary |
| --p-bp-md | 980px | narrow / wide screen boundary |
Component primitives are the "smallest correct units" of the site UI. Each primitive exposes variants along only two dimensions — variant / size — with appearance driven by tokens, so it naturally supports light / dark mode and customizable theme colors.
| Scenario | Use |
|---|---|
| Primary action (submit / confirm) | Button variant=primary |
| Secondary action / cancel | Button secondary / ghost |
| Destructive action (delete / abort) | Button danger / danger-soft |
| Status marker | Badge |
| Toolbar filter / model switch | Pill |
| 2–5 mutually exclusive options | SegmentedControl |
| Top tabs | Tabs |
| Switch / multi-select | Switch / Checkbox |
| Scrollable regions with overlay controls | ScrollArea |
| Floating content card / list action menu | Card / Menu |
| Inline notice / global toast | Banner / Toast |
| Dialog / confirmation · bottom panel (mobile) | Dialog / Sheet |
4 semantic variants × 3 sizes. The primary action primary takes its color from the current theme color (§05 can switch between the blue and black families). Radius uses --radius-md uniformly (small size --radius-sm), weight 600, with a visible focus ring.
<Button variant="primary" size="md" :loading="submitting">Save</Button> + // variant: primary | secondary | ghost | danger | danger-soft + // size: sm | md | lg
Unified into three sizes — 26 / 32 / 44px — with the neutral --color-hover wash on hover and a visible focus ring. Replaces the ad-hoc icon + click areas scattered across components today.
sm 26 / md 32; on touch devices the tap target should be ≥ 44px, so use lg 44px, satisfying the §01 accessibility principle (the mobile three-piece set uses lg). Collapsed into two kinds: Badge (status badge, with an optional status dot) and Pill (the clickable pill in the composer toolbar). Radius, font size, and padding are all unified.
Kbd renders a shortcut as keycaps — one block per key, never inline text like (⌘K). Caps are 18px tall (Badge sm rhythm): transparent ground with a 0.5px hairline edge, 11px --font-kbd (Inter + system-ui), text colour inherited from the row that carries it — the cap has no fill or colour of its own, so it follows its context (bright inside the accent-ringed recording box, quiet in a hint row). Typical placement: pushed to the row's trailing edge, opposite the label (e.g. the sidebar search row), and inside dialog navigation hints.
All cards across the site share one structure — head / body / foot — and come in two tiers by visual weight:
0.5px hairline, --radius-md, no shadow. The head is compact mono with no fill, low weight by default, not competing with the conversation.--radius-lg, a faint popover shadow (--shadow-menu), a plain dark title head, and a hairline footer whose actions read in number-key order (chips on the buttons) leading to one solid primary action. No semantic color band.head / body / foot; operation cards are flat + 0.5px hairline + radius-md with no shadow, while the attention card is the single exception — raised surface, radius-lg and a soft shadow, because it floats above the transcript in place of the composer.Unified 38px height (32px small), --radius-md radius, --color-surface-overlay background, and a unified blue focus ring (0 0 0 3px accent-soft). Select is a custom combobox and listbox, not a native <select>; opening it centres the selected option in the scrollable menu. Open Select roots enter the dropdown layer; containing settings groups temporarily release clipping and join that layer so later sections cannot cover the menu.
Diff controls: The non-selectable branch summary starts with a 14px branch icon, aligns to the panel header's 12px inset, uses 12px labels, and ends with a 0.5px hairline. List and tree choices use the 14px list and tree-view registry icons. Flat-list and tree-view paths use the UI font at 12px. Tree roots share the flat list's 14px content inset, then each depth advances by 12px and adds a grey indentation rule.
Diff empty state: Centre the clean-workspace message in the available panel height and lead with a quiet 32px status icon.
Diff detail body: the right-side diff detail reuses HighlightedCode unframed (the panel owns the edge and scroll) — shiki highlighting with the language inferred from the file path, an old/new line-number gutter, hunk headers as a muted band, at the shared code size --code-font-size (12px at Medium, one step below body text). The file preview's code body (text / JSON / HTML and Markdown source) renders through the same component with a per-row number gutter plus search-hit / jump-target row states.
Inline code, code blocks, and diff contents use the monospace font (--p-font-mono); diff change counts and branch summaries use the UI font. Code blocks have a filename title bar and a copy button; the action edge uses a compact 6px inset. Diffs use + / - row colors to express additions and deletions — additions use a success light background, deletions use a danger light background, with no gradients.
jwt.verify(token) to verify the signature, returning 401 on failure.import { verify } from './jwt';
+
+ export function auth(token: string) {
+ return verify(token, process.env.JWT_SECRET!);
+ }One dialog primitive replaces 6 hand-written implementations: unified --radius-xl radius, --shadow-xl shadow, 20px head padding, right-aligned footer actions, and an IconButton close button.
md 440 / lg 640 / xl 760 (--p-content-max) — chosen by content weight. Height comes in two kinds: auto (default, grows with content up to max-height) and fixed (constant height min(680px, 100vh - 64px), with overflow scrolled inside the body). Content / multi-tab dialogs (settings, model picker, provider manager, folder browser) always use fixed so the frame size stays constant and doesn't jump when switching tabs or content length; short confirmation dialogs keep auto. Selectable controls inside Settings use 0.5px hairlines. Its navigation stays transparent on the grouped canvas — separated from the content region by the 0.5px hairline (horizontal in the stacked mobile layout) — and uses 12px labels at weight 525 with 16px registry icons; the selected tab paints the same neutral --color-hover wash as hover, with the label simply brightening to --color-text — the Kimi app settings nav's recipe (.ss-nav-item--active → Fills-F1, no accent tint, no weight change); section captions use 16px UI text in --color-text. Every setting row has a plain-language description; option labels use --color-text at weight 475 with a 1px gap before that description. Chinese descriptions use “思考” and “计划模式” rather than the English terms; “skills” stays lowercase when it appears within a sentence. Every settings section puts its rows inside one rounded group with 0.5px dividers; the content region paints the flat --color-surface so each group (--color-surface-raised) reads one rung above it — never a sunken pit, which would sink the dialog's content below its chrome in dark. The font-size stepper is a compact 32px UI-font control with 12px values and custom minus and plus buttons. Its 52px desktop row centres the control with equal space above and below. Archived workspace headings reuse the sidebar’s folder-closed registry icon, and Restore actions lead with the undo icon. Archive counts use weight 500; timestamps and workspace paths use the UI font. Dialog backdrop: Use a restrained 28% neutral overlay so the workspace remains legible without competing with the modal.
Settings regions: The settings title and close action belong to the right content region. The navigation is a separate full-height region that starts at the dialog's top edge, not content beneath a dialog-wide header.
Archived sessions: Start with the localized page title. Do not add a repeated English kicker above it.
Settings interaction: Notification labels and descriptions are not selectable; their switches remain fully interactive.
Conversation chrome: Header labels are not selectable; the rename input remains selectable and editable. Branch names start with a 14px branch icon. The overflow trigger is a compact 24px control with a 14px icon. Below a 720px header container, hide the workspace prefix and give the conversation title the available width. On macOS desktop the header doubles as the window-drag region and interactive controls opt out with no-drag; while one of its menus or a dock work panel is open every window-drag strip (chat header, sidebar header, panel header) drops the drag region so an outside press anywhere reaches the page and dismisses the overlay (window dragging is simply paused).
Session search: follows the §09 flush picker anatomy — a boxed Input under the head, and a result list that fills the body's available height and owns vertical scrolling.
Model picker: follows the §09 flush picker anatomy; the provider filter remains horizontally scrollable without showing a persistent scrollbar. Only the model list scrolls; the shortcut bar remains pinned at the bottom.
Unified information architecture: status icon + title + description. The status color appears only on the icon, avoiding large colored areas that create visual noise. For an undoable action there is a second, lighter form — the Action toast (ActionToast.vue): a pill floating top-center just below the 48px header, carrying a one-line sentence whose actions are plain inline <button>s (styled accent by the component), plus close. Self-timed (default 8s, hover pauses); the parent re-keys to reset and wraps it in a <Transition>. First used by session archive (Undo / Settings); warnings keep the bottom-right Toast stack.
Loaders fall into two categories by scenario — do not mix them:
WorkingIndicator — the 小蓝 mascot (KimiMascot, the kimi.com avatar Rive asset, with a static SVG fallback under reduced motion or when the runtime fails) plus a phase label. All other loading states use the plain Spinner.Inline text link: the default is the accent color with no underline; on hover it shows an underline and darkens. File links inside inline code use a 1.5px underline offset so the line stays clear of the chip background. The .muted variant uses the secondary text color. Used for in-text jumps, external links, "view all", and other lightweight actions.
Desktop menus use a 3.5px panel inset. Standard items use 5px × 9px padding and a 7px icon gap. Their three-layer neutral shadow stays below 4% opacity.
Dropdown menu panel: frosted glass — the translucent --color-menu-bg fill over a blurred, saturated page backdrop (--p-menu-backdrop) — plus hairline + light shadow (--shadow-menu, a three-layer neutral ramp). This is the one place glassmorphism is the design language rather than an exception (§06); every floating menu surface (Menu.vue, the Select listbox, composer dropdowns, slash/mention popups) uses the token pair, never ad-hoc blur values. Menu items support icons, the current (active) state, the danger state, and the disabled state, with separators grouping items. All menu actions use 13px labels at weight 475 with 16px leading icons; both share a 16px line box for vertical alignment. Menu timestamps use the UI font. On touch / mobile, use lg (≥44px row height) while keeping the same type size. A dropdown menu pops in from its trigger corner — fade plus a slight 0.97 scale over --duration-base (exit --duration-fast), the composer model dropdown's motion language; the transform origin and the nudge direction follow the anchoring, including the upward flip near the viewport edge.
Row states: hover uses the mode-aware --color-hover wash (it lightens under dark, never darkens); a leading icon sits one rung below the label (--muted), and on hover both label and icon step up to --color-text-strong, the max foreground tier. Selection keeps the accent pair (--color-accent-soft / --color-accent-hover); danger keeps its own colour.
Mutually exclusive short option groups, commonly used for 2–5 option switches such as "light / dark / follow system" or the four font-scale steps. Options may include a 14px registry icon or a colour swatch. A single raised indicator with a soft shadow (no border — the edge stays clean) slides and resizes between options using the standard motion tokens. Three sizes: md (default, settings pages), sm (compact rows), and xs (dense menus such as the composer model dropdown — 20px items, 12px labels).
Linked model + thinking-effort picker (settings → Agent → Subagents, experimental; components/settings/SecondaryModelPicker.vue, shared by both ends). Use it whenever two choices are only valid as a pair — here an effort is meaningless without its model, and every model declares a different supported set. It is a cascading variant of the §03 Select: the trigger is the Select trigger verbatim (value renders model · effort, the unset state uses the placeholder tint), and the dropdown opens as a SINGLE-LEVEL model list (grouped by provider) on the floating menu surface (--color-menu-bg / --p-menu-backdrop / --shadow-lg). The menu teleports to <body> with position: fixed — it opens on top of the settings modal (on the --z-modal-dropdown rung), and only a body-level surface escapes the dialog's scrolling-body clip; it re-anchors to the trigger on any outside scroll and closes on window resize (the UserMenu teleport's full recipe). Hovering or clicking a model row flies its effort submenu out to the RIGHT of the row — same menu surface, anchored to the row's live position, flipping to the left only near the viewport edge per the §03 anchoring rules — with a 250ms hover-intent grace (the UserMenu flyout's recipe) so the diagonal path into the submenu doesn't collapse it. Every model row carries a trailing chevron-right affordance; clicking an effort confirms the pair and closes — one atomic write, never two staggered patches. Flyout options follow the composer's thinking-level model (segmentsFor): effort models get off + their declared levels (always-thinking ones get no off), boolean-thinking models get on/off, unsupported models get off alone; while no effort is set at all, a "Model default" entry leads (it writes the model alone — POST /config merges and cannot clear a stored effort, so the entry disappears once one is set). A configured effort the model no longer declares is appended as an extra flyout option so the current pair stays visible and re-selectable. Keyboard mirrors the Select contract (focus stays on the trigger, Esc preventDefaults so the hosting dialog does not close): ↑/↓ move within the active level (the flyout follows model moves), → opens the flyout, ← collapses it, Enter confirms, Home/End jump. ARIA: combobox trigger → dialog menu holding a model listbox plus the effort listbox flyout with option rows. The menu itself flips upward when the trigger sits near the viewport bottom.
Tabs with a bottom hairline, used for grouping and switching sibling content. The current tab is marked with accent text + an accent underline.
A two-state switch for settings that take effect immediately. The 36×20 track has a 0.5px hairline and full radius; its 16px knob uses 1.5px internal offsets so the visible inset remains 2px and symmetric after accounting for the border. On hover, the knob eases to an 18px rounded rectangle towards the track centre. When on, the track turns accent and the knob slides right.
A 17×17 checkbox. When checked it fills with the accent color and shows a white tick (inline SVG). Often paired with a text label.
A 32px default avatar with md radius; .sm is 24px. Can hold an initial or an icon; falls back to this placeholder when there is no image.
A centered placeholder for empty lists / panels: a 48px faint icon + title + hint, avoiding blank pages.
A 0.5px hairline divider (--p-line); .p-divider-v is the vertical divider, used between inline elements.
A CSS-only hover hint, wrapped in .p-tip. Inverted background (--p-text / --p-bg), single line, no wrapping — carries only short notes.
An inline notice bar placed at the top of a content area. Three states — .info / .warning / .danger — each with a matching 18px icon.
A mobile bottom slide-up panel: xl top radius + drag handle, xl shadow. At ≤640px, dialogs become bottom-anchored Sheets.
A placeholder for loading content, using a breathing opacity animation (no gradients), following the no-gradient-text rule. Composed into titles / text lines / avatars.
An inline combination of "primary action + command text + copy", sitting between a button and a code block — used for install / onboarding / one-click execution. The primary action reuses Button primary; the command area uses a mono light-grey background.
The application top bar. Solid by default; the .frost variant is translucent + background blur, used only for sticky navigation bars. Together with the floating menu surfaces (Menu / Dropdown), it is one of the two exceptions to the no-glassmorphism rule (see §06).
The in-transcript find bar (Cmd/Ctrl+F), implemented by components/chat/TranscriptSearch.vue. A floating card pinned to the transcript's top-right (top: --panel-head-h + --space-3, right: --space-3 — equal inset on both axes), --z-sticky, raised surface + 0.5px hairline + --shadow-menu. One radius for both states: --radius-2xl is a full capsule at the collapsed height and a card once the footer expands — never animate between two radii.
| Part | Rule |
|---|---|
| Input row | Search icon (muted) + bare input — the list-style bare-input exception family (sidebar search row, inline rename), NOT the boxed Input primitive; the 38px bordered control would break the pill. Circular close IconButton sm (concentric with the capsule end); a 0.5px hairline separator before it. Height comes from the grid: 32px control (--space-8) + 2× --space-1 padding = 40px — at which --radius-2xl is exactly the half-height capsule. |
| Footer (results) | Expands via the 0fr→1fr grid fold (--duration-slow), hairline top separator, prev/next IconButton sm left, right-aligned muted count (N/M results · --ui-font-size-sm). Only exists once a query has settled — while typing or empty, the bar stays a bare pill. |
| States | collapsed (empty query) / searching (Spinner sm in the input row during the ~800ms debounce) / results / no-results (count reads "No results", nav disabled). Disabled is uniformly opacity:.5. |
| Focus | Composer-style: a neutral hairline overlay (::after + --color-composer-focus-line) fading in on :focus-within. No accent ring. |
| Match ink | CSS Custom Highlight API — the bar mutates no transcript DOM. All matches: --color-search-match (yellow); current: --color-search-match-current + a 2px --color-warning outline ring (a positioned overlay — highlight pseudos can't paint box outlines). Tokens live in web-ui/style.css with light/dark pairs. |
| Keyboard | Cmd/Ctrl+F opens + focuses (repeat = re-focus + select-all; hardcoded, reserved in the desktop keymap), Enter / Shift+Enter steps matches (wrapping), Esc closes from ANY control inside (container-level, so it never reaches the conversation's Esc-abort). |
| Matching semantics | Rendered transcript DOM only (unloaded older pages are out of scope), capped at 1000 matches (count reads N/1000+). Matches span inline nodes within one block, never cross block breaks; inert and display:none content is excluded. Stepping scrolls the match's own rect into view, not its parent element. |
A small group title for sidebar lists, used to section the content below (such as Workspaces in the sidebar). Spec: 13px / 700 / uppercase / letter-spacing .08em, color --color-fg-faint; left-aligned to the row's starting padding (--sb-pad-x), keeping the same indent as the group rows below. For scripts without case (such as Chinese), text-transform:uppercase simply has no effect — no special handling needed.
The message stream is the core of Kimi Web. Tool calls render as quiet activity lines — one borderless line per call, bespoke per tool kind, auto-grouped, expanding on demand — while Question / Approval elevate to a floating neutral surface because they need a decision, and the Swarm composite keeps a card; the Composer collapses into a single rounded container.
User-message bubbles follow the kimiwork production recipe (MessageItem .user-bubble): a neutral --color-user-bubble-bg fill (BubbleGray — #f5f5f5 light / #292929 dark), uniform --radius-lg corners, no border, no shadow.
Message timestamps use 12px UI text at weight 500, matching the compact metadata scale without switching to a monospace face.
The user-message metadata row sits one 8px spacing step below the bubble, so its actions and timestamp read as supporting information rather than part of the bubble edge.
Overlong user messages clamp at 10 measured lines, the tail dissolving through an alpha mask rather than a tint overlay (the translucent accent fill would double-composite); a floating pill toggle centred on the fade expands in place and collapses back, and the collapse pins the toggle itself so the reading position survives. Skill / plugin command args clamp through the same wrapper, beside the card head. Like the transcript's other disclosure controls (thinking row, turn fold, tool lines), the toggle is a bare native button carrying aria-expanded — chat-surface disclosure controls do not use the §03 Button primitive.
The floating jump-to-latest control uses 12px UI text at weight 525, led by the full down-arrow icon rather than a disclosure caret.
Thinking is an inline, borderless disclosure row in the message stream — never a side panel. The k15 bulb (the thinking registry icon) leads the row in every state; while streaming the "Thinking…" label breathes (opacity only, never a gradient shimmer) and whole elapsed seconds tick beside it, afterwards the label settles to "Thinking process" with the final span as · Ns (renderer-measured, live sessions only — history shows no seconds). Collapsed by default, it expands in place with the standard grid-rows animation and a 90° chevron rotation, and it folds itself back once the stream moves past it, even if the user expanded mid-stream. The header only animates its text colour on hover (standard duration and easing tokens), no card shell.
I looked at the structure of src/auth; it is currently based on a session cookie. The scope of the change is below — once you confirm, I'll start.
src/auth/middleware.ts, 42 lines changed. Allow?Wide markdown tables (desktop): regular chat prose stays within the 760px reading column (--p-content-max), and tables stay there too by default — an overflowing table scrolls horizontally inside its own wrapper, so the page and the chat area never scroll sideways. A clipped table shows a gradient fade at its truncated right edge, and hovering the table reveals a small widen button at its top-right corner; clicking it lets the table grow naturally with its content up to 1040px (--p-table-max), centred within the conversation pane, and clicking again restores the default width. At the default width a single column is capped at 36% of the pane; once widened the cap relaxes to 700px (--p-table-cell-max), so long cell content wraps inside the cell instead of stretching the table. The conversation outline (TOC) keeps its usual position just outside the reading column; when a widened table grows past it and scrolls under the rail, the TOC is hidden temporarily and returns as soon as the table leaves, without touching the user's TOC setting. On mobile a table never breaks out of the reading column.
High-frequency calls like read / bash / grep are "operational noise" — boxed, collapsible cards quickly drown out the conversation. Tool calls therefore render as one quiet borderless line in the message stream — never a card — and each tool kind composes that line for its own content, so the stream reads like an activity log rather than a pile of widgets. The three visual-weight tiers:
src/auth/middleware.ts, 42 lines changed.<button> carrying aria-expanded (keyboard path); the head itself is a plain click target (mouse path), so trailing slots may hold genuine buttons of their own (e.g. Agent's "open detail").--color-text — the one interactive place to go); the action label (Run / Read / Edit…), the mono command / pattern and secondary context all sit at --color-text-muted; auxiliary elements (glyphs, chevrons, trailing meta) stay --color-text-faint. The stream thus reads in three quiet tiers: prose in text, tool lines in muted, thinking / captions in faint. Line content is centre-aligned so mono-only rows (Bash) sit level with the icon and chevron. Truncating line content (the CSS-ellipsis spans) sets --leading-tight rather than the row's line-height: 1 — a 1em line box is shorter than the font's ascent + descent, so overflow: hidden would clip descenders (j / p / g / y); mono runs take the font's own normal leading instead, since JetBrains Mono's ≈1.32em metrics exceed --leading-tight. The 16px chevron still drives the ~24px row height.:line-range or a +N −M stat with a mini segmented bar; Grep shows the pattern in mono plus a match count; Glob / Ls list paths; Todo carries the active task with a done/total progress bar; goal tools show a coloured status pill; ExitPlanMode expands into a read-only plan receipt with its persisted review outcome. Unrecognized tools fall back to glyph + localized label + argument summary.--shadow-xs, flush with the stream's left edge, ≤560px). The card echoes only the picks, checked with the live QuestionCard's CSS glyph language one step down (14px); passed-over options are not echoed. Dismissed (or zero-answer) collapses to a slim italic one-line card; while running, and for unrecognized output (background launch / error), it stays the plain quiet disclosure line with the raw output.path:line opens the preview at that line). Code-bearing details — the Read content, the Edit diff, the Write content — are syntax-highlighted by file type (github-light / github-dark, following the colour scheme), with the Read output's real line numbers as the gutter; highlighting mounts lazily on first expand and degrades to plain text for unknown languages or oversized content.Read 2 files · Ran 5 commands (1 failed) · 26s), the failure clause hanging on its kind in danger red, the total span faint at the tail — one line, ellipsis-truncated, the full sentence in the title tooltip. Thinking items fold into the run but are not narrated in the sentence. The row shares the thinking row's language (borderless faint text row, text-colour hover only, one whole-row button with a rotating chevron) but rides a roomier 8px vertical padding — 30px against the quiet lines' 22px, so the turn-level summary keeps its presence between prose paragraphs; while the turn streams through the run the row stays expanded and the summary turns live (current action + cumulative per-kind stats + ticking whole seconds), and once every item settles it folds itself back — even if the user expanded it mid-run (the thinking block's vocabulary); a settled → running transition (the stream appending to the same run) reopens it. The glyph carries the state: the current step's own icon breathing while running, green ✓ / red ✕ once settled. A run needs ≥ 2 steps — a lone step renders standalone as the block it always was. Text never folds (it breaks the run), and neither do successful media tools (no card — inline media is the turn's output); everything else folds, cards included: Todo / Goal progress narration, the sub-agent identity card, Question / Swarm cards and unrecognized kinds (skills, MCP tools) all join the run — the stay-expanded-while-live rule keeps a card visible exactly while it is active. The expanded run is the items flat in order (thinking rows + tool rows), each with its own in-row details intact — the lines keep their own 4px row rhythm but breathe 8px apart, with a small inset below the head.TurnFold.vue): when an assistant turn settles, every block before the LAST text block — thinking segments, activity runs, interim text paragraphs, Todo / Goal / sub-agent cards — folds into a single bare row reading Worked 4m57s (whole seconds, no glyph, no summary sentence), expanding into the folded blocks in order, each with its own rendering intact. The span is the turn's ELAPSED time (turnWorkMs): it ticks from the stamped start while the turn is open — approval/question waits included by design, so no park bookkeeping exists — then reads the daemon's own durationMs once settled (the server message stamps for history turns); the wall clock only feeds the live tick, so throttled tabs, session switches and remounts cannot corrupt the settled value. Without any stamp the row falls back to the generic Work details. Streaming turns show no row and a forced-open body — the live transcript is untouched, the fold lands only when the stream moves past the turn (or the turn parks). The split never hides the turn's output: the final text block and any trailing blocks (inline media, standalone cards) stay visible, and a text-only turn folds nothing. Fold state is a plain component ref — nothing persists, switching sessions resets to folded. Inside the right-side sub-agent transcript, disclosure bodies open instantly while their chevrons retain the standard rotation: animating the height of a full historical stream would relayout the entire panel on every animation frame.Question and Approval — they genuinely need the user's attention. The Swarm composite keeps one quiet card (raised surface, 0.5px hairline, large radius) for its phase overview + member accordion.NotificationCard.vue): the hidden <notification> injections (background-task / sub-agent settlement) render where they landed in the turn — a 28px status chip + title/sub head tinted with the toast status token pairs (completed → success, failed / timed_out / lost → danger, killed → warning, else neutral surface), expanding in place to the fields, the body, an output-file row (copy path) and the raw payload. ≥2 CONSECUTIVE notifications merge into one neutral group card (count + per-item status dots + compact rows, each expanding on its own). Notifications break the activity run but are never turn boundaries, and they never fold — a notification is an event worth noticing, not process noise, so it punches out of the turn fold and renders right after the fold row, in order.goal_continuation trigger (goal mode's self-driven next turn — a turn boundary, unlike task notifications) never renders its machine prompt; instead the assistant turn it opens shows one faint 12px line flush with the stream's left edge — the target glyph shared with the Goal tool (this turn belongs to the goal) + a localized label — ABOVE the turn's content and OUTSIDE the turn fold, so the row survives as the turn's provenance after settling. The marker lands with the trigger (before the first assistant block), and while the newest exchange is a goal-continuation turn the undo affordances (edit-and-resend, Esc undo) are suppressed — rewinding would drop the hidden trigger while refilling the older user text.TurnFilesSummary.vue): between the turn's final text and its footer, a §03 Card (hairline border, no shadow — NOT the quiet tool line, the artifacts are worth a discrete object) lists every file the turn's Edit / Write calls touched. The head reads "N files changed" with the aggregate +A −D and the mini diffbar; the aggregate hides whenever any row's stats are incomplete (a Write or an underivable edit makes the total a lower bound, never presented as exact). Each row is one clickable workspace-relative path (short and self-locating; a file outside the cwd stays absolute) with its per-file +A −D at the right edge. The row's action keys on the tool kind, and the stats tell it apart: a Write has no per-file count (its diff is underivable) and opens the whole file in the preview; an Edit / MultiEdit carries its +A −D and opens that file's turn diff in the right-side detail layer (TurnDiffPanel.vue — the turn's own X→Y change, not the git diff), whose header keeps an open-file action. The first three files show inline; the rest collapse behind a "N more files" ghost-button row in the card's foot. Where nothing handles the row action (the BTW side chat), the card renders its file rows as plain text instead of links.The two attention cards replace the composer in the dock and share one contract: a floating neutral shell (--color-surface-raised + hairline + --radius-lg + --shadow-menu), a plain dark 16px title head, and a hairline footer whose actions read in number-key order with exactly one accent primary. There is no semantic colour band — the floating card itself is the "needs a decision" signal.
--p-chip-num over --color-inline-code-bg, the same chip vocabulary as option rows and the multi-step chip; exactly one primary action, the rest are ghost. Feedback mode swaps the whole footer for submit / cancel.HighlightedCode (syntax-highlighted, 24-row cap with scroll); Edit approvals render the before/after hunk as a highlighted line diff. Plan / diff / file kinds get a head expand toggle that lifts the cap so the block fills the card; the card itself never exceeds the pane (only the scroll area shrinks). Once the plan scrolls, a soft shadow fades in at the scroll area's top edge — the sidebar's scroll-linked seam language, so clipped content reads as passing under the card chrome.danger-soft filled hint row under the command — detection is a display-layer heuristic on the client.Unified into a single raised container: --radius-composer (32px) with --corner-shape-composer: superellipse(1.5) and a stable 0.5px edge. Focus crossfades a low-chroma line-and-accent edge over --duration-slow with --ease-in-out, while the neutral shadow stays unchanged — there is no added halo and no layout shift. The textarea uses text-autospace: normal for mixed CJK and Latin input. Toolbar controls use a quiet 32px full-round geometry with 8px edge inset; the send button remains a standard 32px circle, with its glyph at 28px (--composer-send-icon-size, the production kimi.com size; it sits outside the --p-ic-* scale on purpose).
Fill and edge tokens: the card's fill and rest border are their own tokens — --color-composer-bg and --color-composer-line — running the kimiwork / kimi.com production input recipe (.chat-input__shell): fill = groupedBackground.secondary (#ffffff light / #1f1f1f dark), rest border = separator.s1 (13% black / 12% white), focus line = fills.f4 (25% in both schemes), and --shadow-input = effect.shadow.inputDefault (0 5px 16px -4px rgba(0,0,0,0.07), kept identical in dark — the hairline carries the edge there). Only colours sit in the tokens; the 32px superellipse shape and the focus-only edge overlay are unchanged.
Send button tokens: the send circle runs on --color-send-bg / --color-send-bg-hover / --color-send-icon (+ *-disabled, --opacity-send-disabled, --shadow-send[-hover]), following the production recipe (.chat-input__send): a neutral labels.primary fill (90% black light / 84% white dark, hover #252525 / 84.8%) with the production lift shadow (0 7px 16px -13px 38% + 0 1px 2px 7%, one step larger on hover), a groupedBackground.secondary glyph, and a disabled state of the same vocabulary — fills.f2 fill with a labels.quaternary glyph at full opacity. The button is disabled exactly when submit would no-op — an empty draft with no ready attachment (image-only sends stay enabled), an upload in flight, or the starting spinner — so disabled is a first-class persistent state, never a fade.
Layering, anchors, and motion: the dock normally stays at --z-sticky so the Latest Messages pill can remain visible above its veil. While any Composer popup is open, the dock temporarily joins --z-dropdown, ensuring permission, work-mode, and model menus always paint above that pill. The permission menu's left edge and the model menu's right edge each follow their own trigger pill. All three menus use --shadow-menu and the same trigger-corner pop motion as Session Row menus: 0.97 scale with a 2px shift toward the trigger, --duration-base on entry, and --duration-fast on exit.
Attachment strip: attachments hang inside the composer card above the textarea as two grouped rows — images/videos as shared MediaThumb rounded thumbnails, files as the shared AttachmentChip pill — the same pair the sent bubble renders, so a draft looks exactly like the sent message. File-store videos render a static play tile instead of fetching a first frame. The strip caps at two thumbnail rows and scrolls beyond that instead of pushing the input down; while overflowing, a quiet count badge pins to the bottom-left and new attachments auto-scroll into view (to the end of whichever group grew). With two or more attachments, a one-click clear-all pins to the strip's top-right corner as a quiet 22px badge (trash glyph, danger on hover). The composer's pending preview and the bubble's media clicks open the same MediaLightbox preview, which owns Escape via the shared dialog stack: images go through PhotoSwipe (lib/mediaPreview.ts) and zoom out of the clicked thumbnail (scrim = --color-scrim-strong, caption = --color-text-on-scrim), videos keep the custom modal.
--color-text fill with a --color-bg glyph (never the accent), disabled while the input is empty or an upload is in flight. The transparent dock floats over the transcript, while the scrolling content receives bottom padding equal to the live dock height so its final item can still clear the composer. Composer chrome is not selectable; only the message input permits text selection. Each permission mode has its own registry icon — manual hand, yolo shield-question, auto full-access — paired with the label in the pill (collapsing to the accessible icon below a 620px composer container) and leading its dropdown row in the mode's colour, with the current row's check trailing the row's end. The right toolbar is the flexible region: the model pill shrink-wraps its content, then shrinks and truncates internally only when the toolbar runs out of room. The dock's workbar above the composer carries one pill vocabulary — 32px high with --space-4 inline padding and stadium-shaped (--radius-full) corners, a --color-surface fill (one rung above the page in both schemes — sunken is degenerate in dark — the same material as the popover it opens), and the system hairline edge (0.5px at --color-line-strong, one rung up for presence; no shadow), icon + label + a count or status — for background bash tasks, background sub-agents, todos, and the goal alike; a pill toggles the shared work panel (itself at --radius-xl with the same 0.5px --color-line-strong edge outside — inner separators stay --color-line — and the menu panel's --shadow-menu), and the goal's detail (full objective, completion criterion) fills the panel body while its pause / resume / cancel controls ride the panel head (the decision cards' action vocabulary — exactly one accent primary, resume while paused; secondary pause while active; danger-soft cancel) and the meta counts (turns / tokens / time / budget) sit in a hairline footer — never a separate full-width strip. Workspace attachment card: on the empty session, the workspace picker is a separate attachment card tucked under the composer — and the composer card itself stays complete (its own 0.5px border, --radius-composer corners with --corner-shape-composer, and shadow are never altered). The attachment lives inside the composer's padding box as the card's sibling, so its width always matches; its top --space-4 slides behind the card (the card is raised to --z-sticky), its square top edge stays hidden, and only the rounded bottom (0 0 --radius-xl --radius-xl) shows. Background --color-hover at 60% via color-mix (≈0.03 black in light, self-adapting in dark), no border, no shadow. Inside sits one quiet capsule trigger: transparent, --radius-full, 16px leading icon and 12px label at weight 475 in --color-text-muted; hover deepens to --color-selected and the label turns --color-text. The dropdown follows the §03 menu spec and is viewport-aware (flips above when more room, clamps max-height to the scrollport); at --z-dropdown it outranks both the card and the fixed click-outside backdrop (--z-sticky), which renders outside the composer because the card's container-type captures position: fixed descendants.
See §02 --p-bp-sm for the breakpoint. This section only gives mobile-adaptation pointers for the chat interface; a full mobile mockup is out of scope for this spec.
Kimi Web uses one unified theme: the same components, fonts, radii, shadows, and surfaces — theming only swaps color values. Every semantic color token ships a light value in :root and a dark override in the data-color-scheme blocks; the semantic status colors (success / warning / danger) are independent palettes, one set each for light / dark.
The app has one accent: the brand blue (--color-accent, #1783ff light / #58a6ff dark). Use it sparingly — the accent is reserved for the primary action, focus rings, links, and active marks (current tab, toggles); large fills always come from the neutral surface tokens. Selection that means "where I am" (sidebar rows, list pickers) is deliberately NOT accent-tinted — it uses --color-selected so it reads as location, not as an action.
Each semantic token ships a light value in :root and a dark override in the two data-color-scheme blocks (explicit choice, or following the OS preference via prefers-color-scheme). Switching light / dark simply swaps between these two sets of derived tokens, with zero structural change.
Anti-pattern rules that all UI code must follow. These rules are also the basis of the check-style detection script, one-to-one with a warning.
| Rule ID | What it detects | Action |
|---|---|---|
| no-gradient-text | gradient text / gradient background | Forbidden |
| no-glassmorphism | backdrop-filter: blur (TopBar sticky nav bar and menu surfaces via --p-menu-backdrop are the exceptions) | TopBar + menus exempt |
| no-color-glow | colored / large-radius box-shadow glow | Forbidden |
| no-emoji-icon | using emoji as a functional icon (no exceptions). Emoji inside user content — session titles, messages — is not chrome and is out of scope (see §07 Session row's emoji icon) | Forbidden |
| no-hardcoded-hex | unregistered hex color inside a component <style> | Warning |
| no-hardcoded-font | hard-coded font-family in a component (e.g. 'Inter') instead of var(--font-ui) | Warning |
| radius-from-scale | radius value not in {4,6,8,12,16,20,999} | Warning |
| z-from-scale | z-index using an unregistered large number | Warning |
| weight-from-scale | font-weight not in {400,500} | Warning |
Every interactive primitive should define the following states where applicable; missing ones are flagged by the style rules. focus-visible always uses --p-focus-ring (appears only on keyboard focus, see §08); disabled is uniformly opacity:.5.
| State | Button | Input | Card | Menu item | Switch |
|---|---|---|---|---|---|
| default | ✓ | ✓ | ✓ | ✓ | ✓ |
| hover | ✓ | ✓ | ✓ | ✓ | — |
| active / pressed | ✓ | — | — | — | — |
| focus-visible | ✓ | ✓ | — | — | ✓ |
| disabled | ✓ | ✓ | — | ✓ | — |
| loading | ✓ | — | — | — | — |
| selected / active | — | — | — | ✓ | ✓ |
| error | — | ✓ | — | — | — |
| readonly | — | ✓ | — | — | — |
WorkingIndicator component: the 小蓝 mascot plus a phase label — "Requesting…" until the assistant's reply starts, "Working…" once it is streaming. All other loading states (including ActivityNotice) use the plain Spinner. backdrop-filter: blur is banned site-wide, with two exceptions: the .frost variant of TopBar — only in the one place of the "sticky navigation bar", used to stay readable over scrolling content — and the floating menu surfaces (Menu.vue, the Select listbox, composer dropdowns, slash/mention popups), which go through the --color-menu-bg / --p-menu-backdrop token pair so the recipe stays single-sourced. No other component (card, dialog, Toast, panel) may use glassmorphism; violations are flagged under no-glassmorphism, and menu blur with ad-hoc values (anything but the token) is flagged too. Persistent panels that stay open over scrolling content (the dock work panel) deliberately stay opaque — a live backdrop blur re-samples the scrolling page every frame and janks in Chromium. The structural spec for the app shell (three-column grid + right preview panel) and the left session sidebar. These are business-agnostic "skeletons" — components, fonts, radii, and surfaces are reused from §02 / §03, but layout and alignment have their own conventions.
On web it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent auto track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles. (The desktop app adds a second row for its terminal panel — desktop-only, see below.)
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;\n /* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */
| Token | Value | Usage |
|---|---|---|
| sidebar width | 270px default (adjustable) | expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's --p-sidebar-w (264px) |
| --preview-w | 460px | width of the right preview panel when open |
| --panel-head-h | 48px | unified height for all right panel heads + the conversation column head; both use a 0.5px bottom hairline |
| --p-bp-sm | 640px | ≤640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel |
0 ↔ var(--preview-w) and no transition — animating a grid track would relayout the whole app grid every frame (when open it squeezes the conversation column, rather than switching templates).min-height:0; min-width:0, so only the inner scroll containers scroll and the page itself does not scroll.--sb-*)All sidebar rows (group head, session row, New chat, search, and Settings buttons) share 4 custom properties. Their 16px icon slots and --sb-gap place every label on the same x-axis as the workspace name.
| Token | Value | Usage |
|---|---|---|
| --sb-inset | 12px | row box (hover/selected pill) inset from the sidebar edges — matches the brand header's 12px padding |
| --sb-pad-x | 20px | content start x (= --sb-inset + 8px row padding) |
| --sb-gutter | 16px | leading icon slot width — matches the workspace folder icon so the session title aligns under the workspace name |
| --sb-gap | 8px | gap between the icon slot and the text |
--sb-pad-x + --sb-gutter + --sb-gap. The group head has a folder icon and the session row has a status slot; both icons are the same width and position, so the titles align naturally. The sidebar from top to bottom: brand header → action group → pinned head (pinned section + "Workspaces" label) → scrolling grouped list (workspace head + session rows) → user-menu footer. New chat and Search are direct sibling controls in the same grid container; the optional new-workspace action shares the first row, while Search spans the next row. A 4px gap keeps Search clear of the scroll boundary. The pinned head sits OUTSIDE the scroll container (the action-group / footer pattern — never position: sticky, which would need an opaque plate over the frosted tint), so the pinned sessions and the "Workspaces" label stay put while the workspace groups scroll beneath; the pinned section is collapsible (a chevron on its label, revealed on hover/focus and kept visible while folded; state persisted) so a long pinned set can't eat the sidebar, and it re-expands when a new session is pinned. Both pinned edges use three light near, middle and far fades across 18px, entering over 260ms only while more session content exists beyond that edge — the top seam lives at the pinned head's bottom border. The footer seam is a 0.5px hairline. Controls reuse the §03 primitives as much as possible. The sidebar sits on --color-sidebar-bg (one step off --color-bg: warm off-white just under white in light, one step BELOW the page in dark — the session column reads as its own plane, and with dark elevation = lighter the chrome never sits brighter than the conversation pane; the hairline still separates it from the pane). Vertical rhythm: the brand header keeps 12px padding (on macOS desktop the left padding grows to 80px to clear the traffic lights); rows inside the action group stack flush (0 gap, same rhythm as the list rows); adjacent groups are separated by 12px. The search glyph has a -0.5px optical correction to align its visual centre with the label. Row hover uses --sb-hover (= the global --color-hover wash); the selected row uses the lighter --sb-selected wash derived from --color-selected — On macOS desktop the sidebar is instead frosted: the window carries a native NSVisualEffectView ('menu' vibrancy, following the in-app scheme via the nativeTheme mirror, its state pinned to inactive so the material keeps its flat pressed-down colour — ≈ #282829 dark / #E7E7E7 light — with no active/inactive drift) and the sidebar column drops --color-sidebar-bg for a single translucent --color-sidebar-tint wash that presses the pinned material one step — ≈ #282829 → ≈ #1e1e1f in dark (rgba(0,0,0,0.25)), ≈ #E7E7E7 → ≈ #f1f1f1 in light (rgba(255,255,255,0.4)) — with header and footer staying transparent so the tint reads as one uniform pane; the root chain (html/body/#app/.app) stays unpainted only under the macos-desktop + vibrancy flags — the latter is the Settings → Appearance accessibility switch (default on; persisted main-side so the window is created with the right material, and live-applied on toggle): off repaints the root chain and the sidebar falls back to opaque --color-sidebar-bg, while the traffic-light layout keeps keying off macos-desktop alone — while the conversation pane, chat header and right preview keep their own opaque surfaces. The list's hover-icon clusters (session-row kebab, group-head actions) paint NOTHING there — no plate, no wash, no blur (real backdrop blur does not even render over this window: Chromium's backdrop sampler returns a flat wash above the transparent BrowserWindow + vibrancy view). Instead the row's title/name dissolves before it ever reaches the buttons: a two-stage mask-image fade — a subtle 16px dissolve at rest, extending over the cluster zone only while the actions are revealed (row hover / keyboard focus / menu open): 34px on session rows (the pin+kebab cluster overhangs the title by ≈25px), 68px on group heads (the floating cluster is ≈60px wide). The fade is zone-based, so short rows render untouched, and text-overflow becomes clip so a long tail dissolves instead of dotting.
| Block | Use | Note |
|---|---|---|
| Brand header | logo + name + collapse IconButton (right-aligned) | on Windows / web the brand is left and the collapse IconButton sm is right-aligned inside the header; the dev-only backend version/address pill uses the UI font, not monospace; the logo is animated (a blinking eye). On macOS desktop the header is a bare drag strip (brand hidden, traffic lights + resident floating toggle over it) |
| New chat | full-width left-aligned button (custom) | 500-weight label; same rhythm as the session rows in the list (left-aligned, hover = --sb-hover). Do not use Button (centered, breaks the rhythm) |
| Search | bare search row (custom) | 500-weight label; no border, hover/focus shows the faint --color-hover wash; icon + label, with the Kbd keycaps (⌘K / Ctrl K) pushed to the trailing edge — label and shortcut are justified apart. Do not use Input (the 38px bordered version is too heavy). It is a direct sibling of New chat in the action group |
| Section label | .p-section-label | uppercase muted small titles like "Workspaces", using --weight-section-label (600) |
| Pinned head | fixed block above the scroll container (.sessions-head): the pinned section (PinnedSessionList.vue) + the "Workspaces" section label | stays put while the workspace groups scroll; owns the top scroll-linked seam (hairline + fade, only while scrolled). The pinned section folds via its label chevron (persisted, kimi-web.pinned-collapsed) and re-expands only on an explicit pin (never on load backfill); the expanded rows are capped at 40vh with their own scroll so a long pinned set can't push the list or footer out of view |
| Workspace head / session row | see next two sections | share --sb-* alignment |
| User-menu footer | account area (components/UserMenu.vue) opening an upward §03 menu | pinned row under the session list, separated by a 0.5px --line hairline; trigger keeps the same list-style family as New chat (24px round avatar + nickname when signed in, user icon + sign-in hint otherwise). The menu box follows the trigger's left edge and width (ResizeObserver-tracked, so it survives a sidebar resize) and is teleported to body because the column's container-type would capture position:fixed. Rows: plan usage / theme / language are macOS-style hover flyout submenus — the parent row carries the module icon, a faint current value and a fixed chevron-right, and hovering (or moving focus to the parent row, or pressing Enter / Space / → on it) opens a teleported panel anchored to the parent menu's right edge (content-adaptive width floored by the menu's own min-width and capped at the parent menu's width; flips left near the viewport edge) with a 250ms hover-intent close grace; the usage panel shows weekly + 5h rows (percent values with severity colours), while the theme (three schemes) and language (two locales) panels move the check to the picked option without closing the menu — then the upgrade entry below the top plan level, settings (with an always-visible Kbd keycap shortcut hint on desktop) and a confirming sign-out; all menu icons come from the Kimi set |
A session row is an inset rounded pill, structured as: status slot → title → time → attention Badge → hover actions (pin / archive).
| Part | Rule |
|---|---|
| Container | padding: 8px 8px inside the list's --sb-inset gutter, radius-sm; no fixed/min height — row height is font-driven (title line-height: --leading-tight, ≈16px) → ≈32px total, the sidebar-wide row rhythm. The hover actions are absolutely positioned so they never force the row taller (no hover jitter). hover = --sb-hover (the global --color-hover wash); active = --sb-selected (75% of the global selected wash) — neutral, no accent tint, no border, no weight change |
| Status slot (lead) | fixed --sb-gutter width; running = Spinner sm, otherwise unread = 7px accent dot |
| Title | flex:1 with truncation and user-select:none; double-click enters inline rename (compact input, not Input), whose text remains selectable |
| Emoji icon | the session icon is the title's LEADING emoji cluster (web-core splitSessionEmoji — no icon field; every client renders the title as-is). The emoji is an ordinary title character — no decoration at rest or on hover (it stays a <button> for a11y), and clicking it opens SessionEmojiPicker — a Menu-shelled panel (bare list-style search row → scrollable sections: Recently used persisted in localStorage (cap 8) + the grouped emoji dataset, with remove/random as MenuItems in the footer; a query swaps the sections for keyword-search results), teleported + fixed + --z-dropdown, popping from the trigger corner like the right-click menu. The menu's "Set Emoji…" opens the same picker and is the discoverable path. Inline rename edits the whole title — the emoji is an ordinary character in the input |
| Time | mono xs, fg-faint; yields to the hover actions on hover |
| Attention Badge | Badge sm: info (needs answer) / warning (needs approval) / danger (aborted) |
| Hover actions | IconButton sm × 2 — pin + archive — cross-faded over the time on row hover (no kebab button). Right-clicking the row opens the full menu (copy ID / rename / emoji / fork / export / pin / archive + timestamp) anchored to the cursor, except over the inline rename input, where the native text-editing menu stays |
| Archive | no confirm — the hover archive button / menu item archives immediately, then App.vue shows the §03 ActionToast (top-center) with Undo (restores the session) and Settings (opens the archived list) |
The group head and session rows share --sb-*: folder icon (open/closed) → name, with the kebab and "+" revealed on hover.
--sb-gap before the name — it does not pad out the --sb-gutter slot.--color-text-muted, one step lighter than session titles), so group heads remain clear without competing with list content. No path subtitle; hovering the name shows the full root path in a Tooltip.IconButton sm inside a floating actions layer anchored to the row's right edge — no reserved layout space, so the name uses the full row width when idle. Shown on hover, keyboard focus, or while the menu is open; the layer backs itself with the sidebar surface (container background) plus the row hover wash (an ::after shown only while the row is hovered), so its color exactly equals the row's current background and the overlapped name tail doesn't bleed through (hidden via opacity:0, staying in the tab order). On macOS desktop the layer paints nothing at all — the name's mask-image fade (see the sidebar section above) dissolves the tail before it reaches the buttons--sb-selected fill as a selected session row (selection reads as "where I am"; the fill wins over hover). Once a session is selected or created, the fill moves to that session row.The "expand / collapse" controls at the bottom of each workspace group are compact list controls (same family as search, New chat, inline rename — not Buttons) sharing one row: expand (chevron-down) first, collapse (chevron-up) after a faint middot when both are present. Expanding reveals the next batch of sessions, fetching the next page from the server only when the locally loaded rows can't cover it — the control never exposes whether a reveal came from memory or the network.
| Part | Rule |
|---|---|
| Row | a single flex row holding the controls, all content-width — hover washes just the button as a snug pill, never the full row. Font-driven height (≈32px like a session row), radius-sm; hover = --sb-hover (no text recolor); :focus-visible uses --p-focus-ring |
| Chevron | sm (down = expand, up = collapse); the row indents by --sb-gutter + --sb-gap so the first button's chevron starts exactly at the session-title x, lining the control's leading edge up with the titles above |
| Label | font-ui, text-xs, --color-text-muted; truncated |
| Separator | faint middot (--color-text-faint) with --space-1 side margins, rendered only when both controls are present |
| Behavior | each group keeps a display cap starting at the first page; "Show more" steps it up by one batch (5) and fetches the next page only when the loaded rows fall short (busy = "Loading…", disabled); "Show less" resets the cap to the first page (view-layer trim — data is kept, no refetch). "Show more" exists while undisplayed loaded rows remain or the server has more; "Show less" appears once past the first page |
A 4px grab strip layered over the 1px column border (margin: 0 -2px makes the whole 4px grabbable) with a centred 2px indicator bar. The bar stays transparent at rest and shows the neutral fills one step up the ramp — f2 on hover, f3 while the drag is live (the sidebar column is translucent on macOS, so f1 read too faint) — never the accent.
| Rule | Value |
|---|---|
| Width / cursor | 4px strip, 2px bar / col-resize mid-range; w-resize / e-resize at the drag limits (hints the direction that still resizes) |
| Normal / hover / drag | transparent / --color-selected (f2) / --color-line-strong (f3) — the neutral ramp one step up, never accent |
| Layer | --z-dropdown, above pane-level sticky chrome (chat dock at --z-sticky) so the overhang stays visible and grabbable |
| Behavior | panel width follows the pointer 1:1 while dragging (the parent disables transitions to avoid lag); on release it is persisted to localStorage |
The right panels (file preview / Diff / compaction summary / sub-agent / side chat) share one track and one head primitive.
PanelHeader primitive (48px = --panel-head-h), the same height as the conversation column head, so the hairline runs as one line.0 → var(--preview-w) with no animation, squeezing the conversation column in a single layout.position:fixed; inset:0).The native terminal (components/terminal/) sits in the conversation column's own bottom grid slot — the sidebar and the right panel span BOTH rows and keep full height (the VS Code layout: the panel belongs to the editor area, not to the whole window). Its height transitions 0 ↔ var(--terminal-h) (260px default, 120 min, 60% viewport max; persisted), squeezing the conversation column above instead of overlaying it. The panel mounts lazily on first open and then stays mounted so xterm scrollback survives a collapse.
row-resize mid-range, n/s-resize at the limits, same neutral f2/f3 ramp, never accent). The shared useResizable hook owns it via axis: 'y'; the height var is written imperatively during a drag (same no-Vue-rerender rule as --preview-w).radius-sm pill (leading terminal glyph, muted while exited + shell label + hover close affordance), the active tab uses --color-selected, hover --color-hover; a "+" action appends a tab. Tabs follow the §08 tablist keyboard model (roving tabindex, ←/→/Home/End), the close affordance is its own button (no nested interactives), and the height separator is keyboard-operable (↑/↓ in steps, value exposed). Trailing actions: restart (only while the active tab exited) and a collapse chevron. Collapsing sets inert on the region — the xterm instances and their scrollback stay mounted but leave the tab order.--color-* tokens at runtime (re-read on scheme flips; the ANSI hues the status ramp doesn't cover use dedicated --color-term-magenta/cyan tokens); the font is the app JetBrains Mono stack sized off the content token scale. While focused, the panel owns every key except the registered app shortcuts (chat-level Esc / find / select-all chords stay inert inside it).ctrl+` (⌃` on macOS — VS Code's binding; ⌘` stays free for the OS window switcher — customizable in the shortcut registry), and the View menu's Toggle Terminal item. New tabs spawn in the visible workspace root. Terminal state is per session: switching sessions swaps the visible bucket while the others keep their PTYs and xterm views alive (scrollback survives a round trip; the ten most recent sessions are kept, LRU). The panel never renders on mobile / web.Kimi Web is a local developer tool; it does not target a specific WCAG conformance level, nor maintain a full screen-reader QA matrix. This section collects only the rules that are "low-cost, don't hurt the look, and directly benefit keyboard-heavy users", as the baseline contract for each primitive; the more expensive, lower-ROI parts (such as real-time announcement orchestration for streaming output) are not mandatory for now.
:focus-visible (not :focus). It appears only on keyboard focus; mouse clicks don't trigger it, so it doesn't pollute the mouse-driven visual; the ring's strength is tuned uniformly with --p-focus-ring, not overridden per place. Anything doable with a mouse must also be doable with a keyboard; Tab order follows the DOM, with no invented skipping. Composite controls define their keyboard model per the table below; a missing model is treated as incomplete:
| Control | Keyboard behavior |
|---|---|
| Dialog | Tab cycles within the dialog (focus trap); Esc closes; focus returns to the trigger element after closing. |
| Menu | ↑ / ↓ move the highlight, Enter selects, Esc closes. |
| Tabs | ← / → switch tabs (roving tabindex); only the current tab is in the Tab sequence. |
| Switch / Segmented | ← / → or Space / Enter to toggle. |
:focus-visible + --p-focus-ring (primary actions may use --p-focus-ring-strong).outline: none is forbidden. To remove the default outline, you must provide an equivalent replacement style.aria-label — IconButton already enforces this with a required label prop.role="dialog" + aria-modal="true", with the title as the dialog's accessible name.aria-hidden="true" to avoid being read out by screen readers.Desktop click targets ≥ 32px; touch devices ≥ 44px (consistent with the §01 principle and the IconButton lg tier).
Handled uniformly in the global styles per §02's @media (prefers-reduced-motion: reduce); components do not check this individually. The chat working indicator's mascot renders its static fallback.
Screen-reader announcements are not a mandatory contract in this product. Short hints like Toast can use role="status" / aria-live; chat streaming output is currently not announced word-by-word, which is an acceptable trade-off, to be added later if a real need arises.
Every overlay in the app — pickers, browsers, managers, confirmations — is built on the single §03 Dialog primitive. This chapter fixes the two layout anatomies allowed inside that frame, plus the row and footer contracts that make all dialogs read as one family. Do not hand-roll a third anatomy.
All dialogs share the §03 primitive: --radius-xl radius, --shadow-xl shadow, a restrained 28% neutral backdrop, a head (title + IconButton close), a body, and a right-aligned foot. Widths md 440 / lg 640 / xl 760 and auto / fixed height are chosen per §03. One interruptive overlay at a time; Esc closes; focus is trapped and restored. A blocking flow that must be resolved rather than dismissed (server token) uses hideClose with closeOnOverlay/closeOnEsc off — never a hand-written overlay.
The default: the body carries its own padding and the caller drops content straight in. Confirmations put their Buttons in the #foot slot (right-aligned, cancel → confirm). Used by: confirm, login, status panel, server token.
:padded="false" with height="fixed"; the consumer owns the zone layout inside a full-height column. The zones below are the whole vocabulary — a picker dialog composes them and adds nothing else. Used by: model picker, session search, folder browser, provider manager.
| Zone | Contract |
|---|---|
| Search | The boxed §03 Input, inset 22px so its edge aligns with the head title. Autofocus on open. No leading icon, no borderless variant. |
| Filter chips | Optional. 28px pill: transparent + muted text by default, --color-hover on hover, --color-selected + medium --color-text when active. Horizontally scrollable with the scrollbar hidden. Never a row of Buttons. |
| List | flex:1, owns the vertical scrolling, padded 4px 8px so rows bleed near the dialog edge. role="listbox"; rows carry role="option" + aria-selected. |
| Row | 8px 12px padding, --radius-md. Two quiet lines: name 14/20 (medium when current) and a meta line 12/18 in --color-text-faint — provider · context · capability labels, dot-separated. No badge rows, no raw-id line (search still matches them). Trailing slot: check icon (current row only), then the star IconButton. |
| Row states | Hover / keyboard-selected → --color-hover; current → --color-selected — a neutral "where I am" fill, never an accent tint, never an inset stroke. The star stays hidden until row hover, keyboard selection, or starred; it is always visible on touch devices and colored --star when starred. |
| State rows | Loading / unavailable / empty: centered on both axes, muted 14px; warning color only for the unavailable case. |
| Shortcut bar | The footer: full-bleed, padding 8px 16px, border-top --color-line, left-aligned. Keyboard hints are Kbd keycaps + 12px --color-text-faint labels, groups separated by "·", the whole bar aria-hidden. An instructional sentence (folder browser) reuses the same bar without keycaps. |
↑/↓ move a keyboard selection (rendered identical to hover) and always scrollIntoView({ block: 'nearest' }); Enter selects and closes; Esc closes.background only (--duration-fast ease-out); the open/close animation lives in the primitive, not in the consumer.| Dialog | Anatomy | Composition |
|---|---|---|
| Model picker | flush · lg · fixed | search + provider chips + model rows + shortcut bar |
| Session search | flush · lg · fixed | search + result rows + shortcut bar |
| Folder browser | flush · lg · fixed | breadcrumb bar + filter bar + folder rows + actions + hint bar |
| Provider manager | flush · xl · fixed | management rows with inset dividers (rows are not selectable) + add section + shortcut bar |
| Confirm / Login / Status | padded · md · auto | title + message or form + right-aligned foot |
| App update (desktop) | padded · lg · auto | version title + quiet meta line (release date · current version) + height-capped scrolling what's-new list / progress bar + right-aligned action row (skip → download, later → restart) with the auto-download checkbox right-aligned on its own foot row below (a pure preference for future checks) |
| Server token | padded · md · auto | hideClose, no Esc/overlay close — resolved only by a valid token |
| Settings | flush · xl · fixed | page-like exception: side-nav region, per §03 |
| Onboarding wizard | not a Dialog | full-page takeover (not built on §03): one centered column (brand lockup → step content → ghost actions + centered primary CTA); selectable options share the option-card pattern — 0.5px --color-line hairline, --color-accent border + --color-accent-soft fill when selected |
C&&(l=C),Tw&&(g=w),d
=this.nodes.length){var V=0;T.forEach(function(M){M.owner==c&&V++}),V==this.nodes.length&&(this.isConnected=!0)}},A.exports=s}),(function(A,G,N){var v,h=N(1);function i(r){v=N(6),this.layout=r,this.graphs=[],this.edges=[]}i.prototype.addRoot=function(){var r=this.layout.newGraph(),a=this.layout.newNode(null),f=this.add(r,a);return this.setRootGraph(f),this.rootGraph},i.prototype.add=function(r,a,f,e,u){if(f==null&&e==null&&u==null){if(r==null)throw"Graph is null!";if(a==null)throw"Parent node is null!";if(this.graphs.indexOf(r)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(r),r.parent!=null)throw"Already has a parent!";if(a.child!=null)throw"Already has a child!";return r.parent=a,a.child=r,r}else{u=f,e=a,f=r;var t=e.getOwner(),s=u.getOwner();if(!(t!=null&&t.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(s!=null&&s.getGraphManager()==this))throw"Target not in this graph mgr!";if(t==s)return f.isInterGraph=!1,t.add(f,e,u);if(f.isInterGraph=!0,f.source=e,f.target=u,this.edges.indexOf(f)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(f),!(f.source!=null&&f.target!=null))throw"Edge source and/or target is null!";if(!(f.source.edges.indexOf(f)==-1&&f.target.edges.indexOf(f)==-1))throw"Edge already in source and/or target incidency list!";return f.source.edges.push(f),f.target.edges.push(f),f}},i.prototype.remove=function(r){if(r instanceof v){var a=r;if(a.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(a==this.rootGraph||a.parent!=null&&a.parent.graphManager==this))throw"Invalid parent node!";var f=[];f=f.concat(a.getEdges());for(var e,u=f.length,t=0;t=r.getRight()?a[0]+=Math.min(r.getX()-i.getX(),i.getRight()-r.getRight()):r.getX()<=i.getX()&&r.getRight()>=i.getRight()&&(a[0]+=Math.min(i.getX()-r.getX(),r.getRight()-i.getRight())),i.getY()<=r.getY()&&i.getBottom()>=r.getBottom()?a[1]+=Math.min(r.getY()-i.getY(),i.getBottom()-r.getBottom()):r.getY()<=i.getY()&&r.getBottom()>=i.getBottom()&&(a[1]+=Math.min(i.getY()-r.getY(),r.getBottom()-i.getBottom()));var u=Math.abs((r.getCenterY()-i.getCenterY())/(r.getCenterX()-i.getCenterX()));r.getCenterY()===i.getCenterY()&&r.getCenterX()===i.getCenterX()&&(u=1);var t=u*a[0],s=a[1]/u;a[0] 0&&(R=p+n.verticalPadding-n.rowHeight[I]);var W;n.width-O>=E+n.horizontalPadding?W=(n.height+R)/(O+E+n.horizontalPadding):W=(n.height+R)/n.width,R=p+n.verticalPadding;var x;return n.width {var f=a(548),e=a(140).CoSELayout,u=a(140).CoSENode,t=a(140).layoutBase.PointD,s=a(140).layoutBase.DimensionD,o=a(140).layoutBase.LayoutConstants,c=a(140).layoutBase.FDLayoutConstants,l=a(140).CoSEConstants,T=function(d,C){var S=d.cy,w=d.eles,P=w.nodes(),B=w.edges(),U=void 0,V=void 0,M=void 0,_={};d.randomize&&(U=C.nodeIndexes,V=C.xCoords,M=C.yCoords);var n=function(x){return typeof x=="function"},E=function(x,Q){return n(x)?x(Q):x},p=f.calcParentsWithoutChildren(S,w),m=function W(x,Q,z,X){for(var rt=Q.length,$=0;$t)return a[0]=f,a[1]=o,a[2]=u,a[3]=U,!1;if(e0&&c.set("dummy"+(c.size+1),B),U},u.relocateComponent=function(t,s,o){if(!o.fixedNodeConstraint){var c=Number.POSITIVE_INFINITY,l=Number.NEGATIVE_INFINITY,T=Number.POSITIVE_INFINITY,g=Number.NEGATIVE_INFINITY;if(o.quality=="draft"){var d=!0,C=!1,S=void 0;try{for(var w=s.nodeIndexes[Symbol.iterator](),P;!(d=(P=w.next()).done);d=!0){var B=P.value,U=f(B,2),V=U[0],M=U[1],_=o.cy.getElementById(V);if(_){var n=_.boundingBox(),E=s.xCoords[M]-n.w/2,p=s.xCoords[M]+n.w/2,m=s.yCoords[M]-n.h/2,y=s.yCoords[M]+n.h/2;Ew&&(g=w),d