Scope. This document is the design system of record for the MiniMax Code Web UI. It was extracted from the shipped desktop client, and it defines the tokens, typography, theme protocol, layout constants, and component contracts the Web UI must adopt.
Authority. The desktop client's colour system is the source of truth. The Web UI adopted it in full, so there is no live divergence left to resolve. §12 records that migration: the Web UI tokens it retired, their desktop replacements, and the exact values.
Not in scope.
packages/webui/server/trajectory/DESIGN.mddocuments a different subject: the Trajectory Studio plugin's design (data sources, MCP vs Mini App, panel revisions). It shares a file name, not a subject. Neither document should absorb the other.Companion.
DESKTOP-ARCHITECTURE.mdcovers the measured desktop and TUI architecture, and the boundary between this alignment work and the Web UI's own runtime.
Every value below was read from the desktop artifact.
| Item | Value |
|---|---|
| Package | @mmx-agent/electron 3.0.73-inside.84 |
| Token source | out/_next/static/css/5ab6aa8aadb6fce6.css (266 KB, holds every :root / .dark mapping) |
| Theme engine | out/_next/static/chunks/58430-c85e4e67ab933a3f.js (injects --mavis-* tokens and the data-* protocol at runtime) |
| Syntax theme | the 35 --code-theme-* tokens in the same stylesheet |
| TUI palette | packages/tui/src/tui/theme/palettes.ts, contracts.ts, syntax.ts |
Extraction is read-only; see DESKTOP-ARCHITECTURE.md §1 for the asar header format and the reader
snippet. The retired Web UI values in §12 are the pre-migration tokens.css, kept as the
baseline the migration was measured against; tokens.css itself is now the desktop stylesheet
verbatim.
The desktop stylesheet is a three-layer system. Reproduce the layering, or alignment degrades into copying colours.
① Primitive numeric scale --{hue}_{step} 8 families × 13 steps, identical across themes
↓
② Semantic role tokens --{role}_{...}_{state} light and dark map to different steps
↓
③ Component the mavis-* skin over antd v5 / antd-mobile
Five rules:
- Application code uses semantic tokens only. No bare hex, no direct
--blue_400reference outside the semantic definitions. This is what makes light and dark isomorphic. - Every semantic token defines both light and dark. The desktop has no dark-only token.
- Interaction states are complete:
default / hover / press / inactive / selected(plusdisabledwhere applicable). A component missing states is unfinished. - Platform and surface differences are carried by attributes, not by stacked media queries — see §8.
- The numeric scale is a shared contract. The TUI already uses it (§13.1), so the brand blue is one value across all three surfaces.
Eight colour families × 13 steps (gray also has _0), constant across light and dark.
This is the only place a hex literal is allowed.
:root {
/* blue — brand family */
--blue_25: #f5fbff; --blue_50: #e5f5ff; --blue_75: #c4e7ff;
--blue_100: #93d2ff; --blue_200: #68c0ff; --blue_300: #3daeff;
--blue_400: #0094fc; --blue_500: #0077d9; --blue_600: #005fb8;
--blue_700: #004b96; --blue_800: #00244d; --blue_900: #001226;
--blue_1000: #000c14;
/* cyan — orbit / decorative (the TUI's `orbit`) */
--cyan_25: #f0fbfb; --cyan_50: #dcf5f5; --cyan_75: #ace7e9;
--cyan_100: #75dcdf; --cyan_200: #1ccdd2; --cyan_300: #00bdc1;
--cyan_400: #00a8ae; --cyan_500: #008e94; --cyan_600: #00767d;
--cyan_700: #005e63; --cyan_800: #003a3e; --cyan_900: #001d1f;
--cyan_1000: #000f0f;
/* gray — the neutral axis; the only family with _0 */
--gray_0: #fff; --gray_50: #fafafa; --gray_75: #f5f5f5;
--gray_100: #ededed; --gray_200: #ccc; --gray_300: #adadad;
--gray_400: #949494; --gray_500: #666; --gray_600: #4a4a4a;
--gray_700: #303030; --gray_800: #262626; --gray_900: #1c1c1c;
--gray_1000: #171717;
/* green — success, diff additions */
--green_25: #edfaf2; --green_50: #d9f4e4; --green_75: #a5e5bf;
--green_100: #80e0a6; --green_200: #4ed082; --green_300: #28c567;
--green_400: #04b54b; --green_500: #009c3d; --green_600: #008635;
--green_700: #00692a; --green_800: #004f1f; --green_900: #082614;
--green_1000: #001207;
/* orange — warning */
--orange_25: #fff6f0; --orange_50: #ffeee3; --orange_75: #ffd0b2;
--orange_100: #ffb485; --orange_200: #ff9452; --orange_300: #fa8237;
--orange_400: #f56811; --orange_500: #e25507; --orange_600: #b9480d;
--orange_700: #923b0f; --orange_800: #4d200b; --orange_900: #311908;
--orange_1000: #1a0a00;
/* purple — code types and functions, video-generation status */
--purple_25: #f9f8fe; --purple_50: #f3f0fc; --purple_75: #e1d7f9;
--purple_100: #ceb9f5; --purple_200: #c29ff0; --purple_300: #b887ec;
--purple_400: #b06add; --purple_500: #9a55c2; --purple_600: #8144a2;
--purple_700: #693584; --purple_800: #331842; --purple_900: #1b0d27;
--purple_1000: #090514;
/* red — error, danger, diff deletions */
--red_25: #fef6f7; --red_50: #feedee; --red_75: #ffc9ce;
--red_100: #ffa3ab; --red_200: #ff828c; --red_300: #ff5e6c;
--red_400: #f73646; --red_500: #e31937; --red_600: #bf152f;
--red_700: #9e0e24; --red_800: #4d0610; --red_900: #33030a;
--red_1000: #140003;
/* yellow — quota and caution */
--yellow_25: #fff9ed; --yellow_50: #fff3d9; --yellow_75: #ffe9b8;
--yellow_100: #ffdb8c; --yellow_200: #ffcf66; --yellow_300: #ffc340;
--yellow_400: #ffae00; --yellow_500: #e09900; --yellow_600: #ba7f00;
--yellow_700: #916300; --yellow_800: #4d3400; --yellow_900: #261a00;
--yellow_1000: #120e00;
/* single accent, reserved for video generation */
--violet_500: #8147f6;
}Step selection follows the desktop's own habits — do not invent new pairings:
| Purpose | light steps | dark steps |
|---|---|---|
| Primary text / icon | _1000, _800 |
_100, _75 |
| Secondary text | _500 |
_400 |
| Tertiary / placeholder | _300, _200 |
_500, _600 |
| Solid primary button | _1000 (near black) |
_0 (white) |
| Status (success / warning / error) | _400 |
_500 |
| Status tint (tag, banner) | _50, _25 |
_900, _800 |
Three independent token families. Do not substitute Tailwind's default scales — the values do not coincide.
:root {
/* radius — the number is the pixel value */
--radius_4: 4px; --radius_8: 8px; --radius_12: 12px; --radius_16: 16px;
--radius_20: 20px; --radius_24: 24px; --radius_32: 32px; --radius_full: 999px;
/* size — icon, control, avatar heights */
--size_12: 12px; --size_14: 14px; --size_16: 16px; --size_20: 20px;
--size_24: 24px; --size_32: 32px; --size_40: 40px; --size_48: 48px;
--size_64: 64px;
/* spacing — a 4-based scale with 2/6 granularity and 90/128 for large gutters */
--spacing_0: 0px; --spacing_2: 2px; --spacing_4: 4px; --spacing_6: 6px;
--spacing_8: 8px; --spacing_12: 12px; --spacing_16: 16px; --spacing_20: 20px;
--spacing_24: 24px; --spacing_32: 32px; --spacing_40: 40px; --spacing_48: 48px;
--spacing_64: 64px; --spacing_90: 90px; --spacing_128: 128px;
}There is exactly one elevation. It is deliberately faint:
/* the .shadow-s1 utility */
box-shadow: 0px var(--shadow-s1-offset-y, 4px) 16px #0000000f;
/* --shadow_default is the shadow/outline colour token, not a box-shadow value */
--shadow_default: <light #0a0a0a14 / dark #0a0a0a80>;--shadow-s1-offset-y defaults to 4px, but the desktop sets it to 0 inside nested scroll
containers. Do the same for cards inside a scrolling list.
--{role}_{variant?}_{state?}
role ∈ bg | text | icon | border | utility | opacity | reference | code-theme | mavis-*
variant ∈ default | grouped | interaction | status | reference | label | on
state ∈ default | hover | press | inactive | selected
The three background families are the easiest thing to get wrong:
| Family | Use | Distinction |
|---|---|---|
--bg_default_* |
page-level containers | primary is the page, secondary a block within it, tertiary a higher-contrast block |
--bg_grouped_* |
grouped lists and cards | inverse of default — in light, default is white and grouped is grey. This is how "page" and "card" are distinguished |
--bg_interaction_* |
interactive elements | split by semantics (primary, secondary, tertiary, accent, danger, positive, warning) then multiplied by state |
*_elevated |
the same token inside an overlay | remapped for dialogs and drawers; see §5.4 |
default / grouped (*_elevated differs only in dark):
| Token | Light | Dark |
|---|---|---|
--bg_default_primary |
#fff |
#171717 |
--bg_default_primary_elevated |
#fff |
#1c1c1c |
--bg_default_scrim |
#fafafa |
#171717 |
--bg_default_secondary |
#f5f5f5 |
#1c1c1c |
--bg_default_secondary_elevated |
#f5f5f5 |
#262626 |
--bg_default_tertiary |
#fff |
#262626 |
--bg_default_tertiary_elevated |
#fff |
#303030 |
--bg_grouped_primary |
#f5f5f5 |
#171717 |
--bg_grouped_primary_elevated |
#f5f5f5 |
#1c1c1c |
--bg_grouped_secondary |
#fff |
#1c1c1c |
--bg_grouped_secondary_elevated |
#fff |
#262626 |
--bg_grouped_tertiary |
#f5f5f5 |
#262626 |
--bg_grouped_tertiary_elevated |
#f5f5f5 |
#303030 |
interaction (39 tokens in total; the most used are listed):
| Token | Light | Dark | Note |
|---|---|---|---|
--bg_interaction_primary_default |
#171717 |
#fff |
solid primary button (inverted) |
--bg_interaction_primary_hover |
#0a0a0acc |
#fffc |
|
--bg_interaction_primary_press |
#0a0a0ae5 |
#ffffffe5 |
|
--bg_interaction_primary_inactive |
#adadad |
#949494 |
disabled |
--bg_interaction_secondary_default |
#0a0a0a0a |
#ffffff0a |
~4% overlay |
--bg_interaction_secondary_hover |
#0a0a0a14 |
#ffffff12 |
|
--bg_interaction_secondary_selected |
#fafafa |
#262626 |
list selection |
--bg_interaction_tertiary_default |
#0a0a0a00 |
#fff0 |
text button, transparent |
--bg_interaction_tertiary_hover |
#0a0a0a0a |
#ffffff0a |
|
--bg_interaction_accent_hover |
#0094fc0a |
#0064ab1a |
brand at 4% / 10% |
--bg_interaction_accent_press |
#0094fc14 |
#0064ab26 |
|
--bg_interaction_accent_focus_highlight |
#c4e7ff |
#00244d |
focus highlight |
--bg_interaction_danger_primary_default |
#f73646 |
#e31937 |
|
--bg_interaction_positive_default |
#04b54b |
#009c3d |
|
--bg_interaction_warning_default |
#fa8237 |
#e25507 |
|
--bg_interaction_video_generation_hover |
#8147f6 |
#8147f6 |
violet, video only |
status (tinted tags and banners):
| Token | Light | Dark |
|---|---|---|
--bg_status_error |
#feedee |
#33030a |
--bg_status_positive |
#d9f4e4 |
#082614 |
--bg_status_warning |
#ffeee3 |
#311908 |
--bg_status_blue |
#e5f5ff |
#0078ff1a |
--bg_status_tag |
#171717 |
#fff |
--bg_status_video_generation |
#9a55c21a |
#9a55c21a |
text and icon are separate token families with identical values. Do not merge them; they
exist so icon contrast can be tuned independently.
--text_default_* / --icon_default_* (hierarchy axis):
| Token | Light | Dark |
|---|---|---|
--text_default_primary |
#171717 |
#ededed |
--text_default_secondary |
#666 |
#949494 |
--text_default_tertiary |
#adadad |
#666 |
--text_default_quaternary |
#ccc |
#4a4a4a |
--text_default_accent |
#0094fc |
#0077d9 |
--text_default_inverted |
#fff |
#171717 |
--text_label_* (text on buttons and labels, by semantics × state):
| Token | Light | Dark |
|---|---|---|
--text_label_primary_default |
#fff |
#171717 |
--text_label_secondary_default |
#666 |
#f5f5f5 |
--text_label_secondary_hover |
#0a0a0a80 |
#ffffffe5 |
--text_label_tertiary_default |
#adadad |
#949494 |
--text_label_accent_default |
#0094fc |
#0077d9 |
--text_label_danger_secondary_default |
#f73646 |
#e31937 |
--text_label_positive_secondary_default |
#009c3d |
#04b54b |
--text_label_warning_secondary_default |
#f56811 |
#e25507 |
status / reference:
| Token | Light | Dark |
|---|---|---|
--text_status_success |
#04b54b |
#009c3d |
--text_status_warning |
#f56811 |
#e25507 |
--text_status_error |
#f73646 |
#e31937 |
--text_status_blue |
#0094fc |
#0077d9 |
--text_status_banana |
#ffae00 |
#e09900 |
--text_status_video_generation |
#9a55c2 |
#9a55c2 |
--text_reference_neutral |
#595959 |
#a6a6a6 |
--text_reference_blue |
#0e7dcb |
#0164aa |
--text_reference_cyan |
#008e94 |
#01767b |
--text_reference_gold |
#c97603 |
#a8670c |
--text_reference_green |
#2da55d |
#068539 |
--text_reference_orange |
#e25600 |
#bd4800 |
--text_reference_purple |
#9244bf |
#7c26ad |
--text_reference_red |
#dc3341 |
#a81c28 |
--text_reference_* is a deliberately desaturated set for quotations and Markdown source
annotations. Use it there; use --text_status_* for semantic states. They are not
interchangeable.
border — built from translucent overlays, not solid greys:
| Token | Light | Dark |
|---|---|---|
--border_default |
#0a0a0a14 (8% black) |
#ffffff12 (8% white) |
--border_light |
#0a0a0a0a (4%) |
#ffffff0a (4%) |
--border_heavy |
#0a0a0af2 (95%) |
#fffffff2 (95%) |
--border_accent |
#0094fc |
#0077d9 |
--border_status_error |
#feedee |
#33030a |
--border_status_success |
#d9f4e4 |
#082614 |
--border_status_warning |
#ffeee3 |
#311908 |
--border_status_blue |
#e5f5ff |
#004b96 |
The base colour is #0a0a0a, not pure black, and alpha comes from the --opacity_black_1_*
family.
Inside an overlay (dialog, [role=dialog], .ant-modal-content, …) the desktop remaps
--bg_default_* and --bg_grouped_* to their *_elevated variants:
:root[data-mavis-desktop-palette]
:is(dialog,[role=dialog],[role=alertdialog],.ant-modal-content,...) {
--bg_default_primary: var(--mavis-overlay-bg_default_primary);
/* … the whole bg_* family */
}The overlay layer itself is derived with CSS relative colour syntax, so it inherits the theme automatically:
--mavis-overlay-bg_default_primary:
rgb(from var(--bg_default_primary) r g b / <alpha>);Web UI implementation: put a data-surface="overlay" scope on the root element of modal,
drawer, and popover components, and remap --bg_* to the elevated variants inside it. One
card definition then works both inline and inside a dialog.
| Token | Light | Dark | Use |
|---|---|---|---|
--utility_overlay |
#00000040 |
#0000003d |
scrim behind modals |
--utility_scrim |
#ffffff80 |
#00000080 |
frosted background |
--utility_popover |
#0a0a0ae5 |
#fffffff2 |
popover surface |
--utility_tootip |
#0a0a0af2 |
#0a0a0af2 |
tooltip surface (spelling preserved from source) |
--utility_scrollbar |
#0a0a0a26 |
#ffffff26 |
scrollbar thumb |
Opacity scale, based on #0a0a0a rather than #000:
--opacity_black_1_{0,2,4,8,15,20,25,50,70,80,90,95}
--opacity_white_0_{0,2,4,8,15,20,25,50,70,80,90,95}
--opacity_purple_500_10: #9a55c21a
The suffix is the alpha percentage: --opacity_black_1_8 is 8% black.
:root {
--mcode-font-family-ui:
"HarmonyOS Sans", "Segoe UI", "SF Pro Display", -apple-system,
BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
"Helvetica Neue", sans-serif;
--mcode-font-family-code:
Hack, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"HarmonyOS Sans SC", "PingFang SC", monospace;
}- UI: HarmonyOS Sans first, then platform UI fonts.
- Code: Hack first, then the generic mono stack. The desktop also ships a JetBrains Mono
webfont (
out/fonts/jetbrains-mono/); prefer it for code blocks. - CJK: the mono stack explicitly lists
HarmonyOS Sans SCandPingFang SC. Keep both — without them, mixed Chinese/English text falls back to a serif for the Chinese runs. - Decorative faces (
Home Hover WenKai,Home Hover Caveat,SourceSerif) are for the desktop home page only and are not part of this contract.
The desktop injects its type scale at runtime as --mavis-* tokens, derived from a scale
object (body, small, assist, h1–h6, tableHeader, tableCell, titleMedium,
remoteGoal, block, each with fontSize and lineHeight).
Measured values in desktop typography mode:
| Role | Size | Line height | Token |
|---|---|---|---|
| body | 14px | 22.75px | --mavis-markdown-body-size / -line-height |
| h1 | 21px | — | --mavis-markdown-h1-size |
| h2 | 17.5px | — | --mavis-markdown-h2-size |
| h3 | 15.75px | — | --mavis-markdown-h3-size |
| table cell | 13px | — | --mavis-markdown-table-cell-size |
| code block | 12px | 20px | --mavis-code-block-font-size / --mavis-code-line-height |
| inline code | 1em scale |
— | --mavis-inline-code-font-size |
| caption | 12px | — | --text-caption-size fallback |
Compact variants used inside the chat transcript:
| Variant | body | h1/h2/h3 | code |
|---|---|---|---|
.matrix-markdown--compact |
13px / 20px | 13px | 12px |
.matrix-markdown--thinking |
14px | 14px | 13px |
Weights:
--mavis-font-weight-normal: <400>;
--mavis-font-weight-default: <400>;
--mavis-font-weight-medium: <500>;
--mavis-font-weight-semibold: <600>; /* headings */
--mavis-font-weight-bold: <700>;
--mavis-font-weight-ui-normal: var(--mavis-body-font-weight, <400>);Markdown spacing inside chat (separate from the global spacing scale):
| Token | Value |
|---|---|
--md-chat-gap-code |
14px |
--md-chat-gap-related |
10px |
--md-chat-gap-short-paragraph |
11px |
--md-chat-blockquote-indent |
24px |
--md-block-gap |
--md-spacing-lg (--md-spacing-md in the thinking variant) |
--md-letter-spacing-* |
all 0 |
Every letter-spacing token is 0. The desktop deliberately does not apply negative
tracking; do not add letter-spacing: -0.02em for a "modern" look.
:root {
--chat-input-height-en: 110px;
--chat-input-height-zh: 146px;
--chat-list-width: 792px;
}The Chinese input area is 36px taller than the English one, to accommodate candidate and multi-line input. Reproduce this branch: a single height leaves Chinese users cramped and English users with dead space.
The desktop applies themes at runtime by writing CSS custom properties onto
document.documentElement and switching scope with data-* attributes.
theme : 'light' | 'dark' | 'system'
themePackId : string default 'mcode-default'
visualThemeSource : 'pack' | 'wallpaper'
appearancePreferences: {
colors: { light: { accentPreset }, dark: { accentPreset } },
uiFontFamily, codeFontFamily, uiFontWeight, codeFontWeight,
density: 'comfortable' | ...,
chatContentWidth: 'standard' | ...,
}
// 1) appearance: html class AND colorScheme — both are required
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.classList.toggle('light', !isDark);
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
// 2) semantic tokens, cached by revision, written one by one
for (const [k, v] of Object.entries(resolveTokens(mode))) root.style.setProperty(`--${k}`, v);
for (const [k, v] of Object.entries(codeTheme)) root.style.setProperty(`--code-theme-${k}`, v);
// 3) hand the appearance to the native layer (Electron only)
window.electronAPI?.setNativeThemeSource?.(theme);| Attribute | Values | Meaning |
|---|---|---|
data-mavis-theme-pack |
pack id | active theme pack |
data-mavis-default-accent |
"true" | absent |
using the default accent |
data-mavis-theme-recipe |
"active" | absent |
theme recipe follows the pack |
data-mavis-icon-style |
style id | icon style follows the recipe |
data-mavis-theme-density |
comfortable, … |
density |
data-mavis-chat-width |
standard, … |
chat content width (omitted when standard) |
data-mavis-ui-font-family |
"custom" | absent |
UI font overridden |
data-mavis-code-font-family |
"custom" | absent |
code font overridden |
data-mavis-ui-font-weight |
"custom" | absent |
UI weight overridden |
data-mavis-code-font-weight |
"custom" | absent |
code weight overridden |
data-mavis-desktop-palette |
revision string | desktop palette injected (overlay scope) |
data-mavis-surface |
web | native |
surface type |
data-mavis-wallpaper |
"active" |
wallpaper mode |
data-mavis-stream-reveal-active |
"true" |
streaming reveal in progress |
data-mavis-stream-reveal-owner |
waapi |
reveal driven by the Web Animations API |
Platform classes on html: mavis-platform-electron (349 uses),
mavis-desktop-typography-enabled (283), mavis-desktop-palette (470).
The desktop stylesheet already contains a web surface variant:
:where(:root[data-mavis-surface=web]) .message-container-chat-content .matrix-markdown {
--md-chat-gap-code: 14px;
--md-chat-gap-related: 10px;
--md-chat-gap-short-paragraph: 11px;
--md-chat-blockquote-indent: 24px;
}Measured: 68 references to data-mavis-surface=web, 10 to =native. The design system was
built for multiple surfaces. The Web UI should therefore declare it:
<html data-theme="dark" data-mavis-surface="web" style="color-scheme: dark">The attribute has no effect until the desktop stylesheet is present, but declaring it now keeps the door open for reusing those rules, and it makes the surface explicit in the DOM.
packages/webui/webapp/styles/tokens.css implements the desktop's own mechanism:
a light / dark class on <html> (app/layout.tsx writes it from an inline
bootstrap, so there is no flash of the wrong theme), a prefers-color-scheme
fallback, and a three-state user choice — light, dark, system — persisted
in the webui:ui:v1:<cid> localStorage envelope. system follows the OS live
rather than only at first paint, via components/appearance-sync.tsx.
The pre-migration frontend instead used a data-theme="light|dark" attribute on
:root with a localStorage["theme"] key. Neither survives: data-theme has one
hit in webapp/, and it is a Mermaid SVG attribute, not a theme switch.
style.colorScheme is set alongside the class, which is what fixes native form
controls and scrollbars.
| Token | Value | Note |
|---|---|---|
--header-height |
60px |
a compact :root override sets 54px |
--left-model-width |
280px |
override 331px |
--right-model-width |
480px |
right panel |
--chat-list-width |
792px |
session list |
--chat-input-height-en |
110px |
|
--chat-input-height-zh |
146px |
|
--share-top-bar-height |
70px |
share view |
--share-bottom-bar-height |
70px |
share view |
--screen |
100vh |
|
--harmony-bottom-bar |
0px → env(safe-area-inset-bottom) |
mobile safe area |
Measured behaviour from the renderer:
- Default width 220px (a 240px variant exists); the file-panel sidebar
(
--file-panel-sidebar-width) defaults to 320px, or 220px when no file panel is open. - Minimum width 220px: dragging clamps with
Math.max(220, Math.min(max, next)). - Collapsed width is 0.
- Layout gutter is 16px (
sidebarLayoutGutter), 0 in some contexts. - Minimum content width 460px (
minContentWidth). - The drag result is written back with
document.documentElement.style.setProperty('--file-panel-sidebar-width', …)and removed when collapsed.
Write the width to a CSS variable during drag rather than to component state — the desktop does this to avoid re-rendering on every frame.
The chat area sizes itself with container query units:
--chat-table-viewport-width: calc(100cqi - 32px);
--chat-table-reading-width: 736px;
--chat-table-gutter: max(0px, calc((100cqi - 768px) / 2));Reading width is 736px, centred once the container exceeds 768px, with 16px inline padding. Tables add scroll affordances:
--table-fade-left: 0px | 32px; /* becomes 32px when overflowing */
--table-fade-right: 0px | 32px;
--table-scrollbar-max-thumb-width: 160px;Use 100cqi, not 100vw: with a sidebar present the viewport width is not the content width.
The chat container needs container-type: inline-size.
| Context | Width | Sidebar | Height | Class |
|---|---|---|---|---|
| Web | 940px | 220px | 600px | mavis-settings-modal-web |
| Electron | 940px | 220px | 700px | mavis-settings-modal-electron |
| Narrow / mobile | 100vw |
260px | 100vh |
Use the web row: 940 × 600 with a 220px sidebar.
mavis-* skin (application code uses this layer)
├── over antd v5 (Button, Modal, Select, Dropdown, Picker, Checkbox, Input,
│ Progress, Tooltip, Popover, Message)
└── over antd-mobile (Grid, Selector, Mask) for compact surfaces
Measured mavis-* classes, by reference count:
| Class | Count | Base |
|---|---|---|
mavis-button |
218 | antd Button |
mavis-dropdown, -root-sub-menu |
117, 67 | antd Dropdown |
mavis-chat-markdown-flow |
106 | custom |
mavis-modal-wrap |
103 | antd Modal |
mavis-settings-modal-electron |
97 | custom |
mavis-surface |
78 | custom |
mavis-input, -no-border |
71, 20 | antd Input |
mavis-select, -popup |
61, 42 | antd Select |
mavis-textarea, -no-border |
61, 30 | antd Input.TextArea |
mavis-checkbox, --round |
54, 51 | antd Checkbox |
mavis-popover-overlay |
34 | antd Popover |
mavis-radio |
20 | antd Radio |
mavis-compact-switch |
16 | antd Switch |
mavis-time-picker-popup |
17 | antd TimePicker |
mavis-settings-content-body, -control |
27, 23 | custom |
mavis-theme-pack-trigger, -menu-option |
24, 29 | custom |
The Web UI does not need antd. It does need the same semantic layer: each component binds its states to the token table below, so the visual result matches rather than merely resembling the desktop.
| Component semantics | default | hover | press | inactive | selected |
|---|---|---|---|---|---|
| Primary button | --bg_interaction_primary_default |
_hover |
_press |
_inactive |
— |
| Secondary button | --bg_interaction_secondary_default |
_hover |
_press |
_inactive |
_selected |
| Text button | --bg_interaction_tertiary_default |
_hover |
_press |
_inactive |
_selected |
| Brand / link | --bg_interaction_accent_default |
_hover |
_press |
_inactive |
— |
| Danger | --bg_interaction_danger_primary_default |
_hover |
_press |
_inactive |
— |
| Primary label | --text_label_primary_default |
_hover |
_press |
_inactive |
_selected |
| Secondary label | --text_label_secondary_default |
_hover |
_press |
_inactive |
_selected |
| Button icon | --icon_interaction_primary_default |
_hover |
_press |
_inactive |
_selected |
| Outline | --border_default |
— | — | --border_light |
— |
| Focus ring | --bg_interaction_accent_focus_blue / --border_accent |
— | — | — | — |
The desktop defines 35 independent --code-theme-* tokens, mapped to the numeric scale
per theme. Do not ship a third-party default theme.
| Token | Light | Dark |
|---|---|---|
--code-theme-default |
--gray_800 #262626 |
--gray_100 #ededed |
--code-theme-muted |
--gray_500 #666 |
--gray_400 #949494 |
--code-theme-comment |
--gray_500 #666 |
--gray_400 #949494 |
--code-theme-keyword |
--red_500 #e31937 |
--red_300 #ff5e6c |
--code-theme-tag |
--red_500 #e31937 |
--red_300 #ff5e6c |
--code-theme-string |
--green_700 #00692a |
--green_300 #28c567 |
--code-theme-number |
--blue_500 #0077d9 |
--blue_200 #68c0ff |
--code-theme-attribute |
--blue_500 #0077d9 |
--blue_200 #68c0ff |
--code-theme-regex |
--blue_700 #004b96 |
--blue_300 #3daeff |
--code-theme-function |
--purple_600 #8144a2 |
--purple_300 #b887ec |
--code-theme-type |
--purple_600 #8144a2 |
--purple_300 #b887ec |
--code-theme-decorator |
--purple_600 #8144a2 |
--purple_300 #b887ec |
--code-theme-property |
--orange_700 #923b0f |
--orange_200 #ff9452 |
--code-theme-builtin |
--orange_700 #923b0f |
--orange_200 #ff9452 |
--code-theme-constant |
--orange_700 #923b0f |
--orange_200 #ff9452 |
--code-theme-invalid |
--red_500 #e31937 |
--red_400 #f73646 |
--code-theme-addition-background |
--green_25 #edfaf2 |
--green_900 #082614 |
--code-theme-addition-foreground |
--green_500 #009c3d |
--green_400 #04b54b |
--code-theme-deletion-background |
--red_50 #feedee |
--red_900 #33030a |
--code-theme-deletion-foreground |
--red_500 #e31937 |
--red_400 #f73646 |
The remaining tokens (class, method, namespace, enum-member, operator, parameter,
punctuation, variable, variable-constant, variable-default-library) alias the
tokens above; do not define them twice:
--code-theme-class: var(--code-theme-type);
--code-theme-method: var(--code-theme-function);
--code-theme-namespace: var(--code-theme-property);
--code-theme-variable: var(--code-theme-property);
--code-theme-operator: var(--code-theme-muted);
--code-theme-punctuation: var(--code-theme-muted);
--code-theme-parameter: var(--code-theme-muted);The Web UI's feature structure comes from the TUI; its visual language comes from the desktop. This section joins the two.
The TUI's TuiThemeColors (packages/tui/src/tui/theme/contracts.ts) has 20 semantic
colours. Measured: the TUI palette is drawn from the same numeric scale as the desktop,
which is the existing basis for cross-surface consistency.
| TUI token | TUI dark | Scale | TUI light | Scale | Desktop target |
|---|---|---|---|---|---|
brand |
#68C0FF |
--blue_200 |
#0094FC |
--blue_400 |
--text_default_accent / --icon_default_accent |
wordmarkHighlight |
#93D2FF |
--blue_100 |
#3DAEFF |
--blue_300 |
wordmark highlight |
wordmarkShadow |
#3DAEFF |
--blue_300 |
#0077D9 |
--blue_500 |
wordmark shadow |
signal |
#68C0FF |
--blue_200 |
#0094FC |
--blue_400 |
running indicator |
orbit |
#1CCDD2 |
--cyan_200 |
#00767D |
--cyan_600 |
no desktop equivalent; keep as its own token |
accent |
#68C0FF |
--blue_200 |
#0094FC |
--blue_400 |
--bg_interaction_accent_* |
markdownHeading |
#CBA6F7 |
Catppuccin mauve | #8839EF |
Catppuccin | --text_default_primary (desktop headings are not tinted) |
markdownCode |
#A6E3A1 |
Catppuccin green | #267A3F |
custom | --code-theme-string |
markdownLink |
#68C0FF |
--blue_200 |
#0066CC |
custom | --text_default_accent |
userMessageBg |
#262626 |
--gray_800 |
#F5F5F5 |
--gray_75 |
--bg_default_secondary |
diffAddedBg |
#213A2B |
custom | #DAFBE1 |
custom | --code-theme-addition-background |
diffRemovedBg |
#4A221D |
custom | #FFEBE9 |
custom | --code-theme-deletion-background |
text |
#D6D6D6 |
custom | #303030 |
--gray_700 |
--text_default_primary |
muted |
#ADADAD |
--gray_300 |
#666666 |
--gray_500 |
--text_default_secondary |
dim |
#666666 |
--gray_500 |
#949494 |
--gray_400 |
--text_default_tertiary |
border |
#303030 |
--gray_700 |
#EDEDED |
--gray_100 |
--border_default |
line |
#666666 |
--gray_500 |
#949494 |
--gray_400 |
--border_default (dividers) |
success |
#28C567 |
--green_300 |
#008635 |
--green_600 |
--text_status_success |
warning |
#FFC340 |
--yellow_300 |
#916300 |
--yellow_700 |
--text_status_warning |
error |
#FF5E6C |
--red_300 |
#E31937 |
--red_500 |
--text_status_error |
Three real differences to resolve:
- Dark status colours: the TUI uses
_300(#28C567,#FFC340,#FF5E6C); the desktop uses_500(#009c3d,#e25507,#e31937). Bright colours read better in a terminal; on the web, follow the desktop. - Markdown headings: the TUI tints headings, code, and links (Catppuccin); the desktop does not tint headings at all.
textanddiff*Bgare hand-tuned and sit outside the scale. Replace them with the desktop tokens.
The TUI uses Catppuccin for cli-highlight (packages/tui/src/tui/theme/syntax.ts):
tone: blue flamingo green mauve overlay2 peach pink red sapphire subtext0 teal text yellow
dark: #89B4FA #F2CDCD #A6E3A1 #CBA6F7 #9399B2 #FAB387 #F5C2E7 #F38BA8 #74C7EC #A6ADC8 #94E2D5 #CDD6F4 #F9E2AF
light: #1E66F5 #DD7878 #40A02B #8839EF #7C7F93 #FE640B #EA76CB #D20F39 #209FB5 #6C6F85 #179299 #4C4F69 #DF8E1D
It also maps down to ANSI16, because pastel colours collapse to white there.
The desktop and the TUI therefore ship two different code themes. Per the authority rule at
the top of this document, the Web UI uses the desktop's --code-theme-* (§10). Converging the
TUI is a separate change.
| TUI component | Semantics | Web UI |
|---|---|---|
box.ts |
bordered container | card / panel — --radius_12, --border_default |
stack.ts, v-stack.ts, h-stack.ts |
layout primitives | flex containers, --spacing_* |
text.ts, truncated-text.ts |
text, truncation | text / truncate, keep a tooltip when truncated |
input.ts |
single-line input | input |
editor.ts |
multi-line editor | textarea |
markdown.ts |
Markdown rendering | Markdown renderer, --md-* spacing, --code-theme-* |
scroll-view.ts |
scroll region | scroll container, --utility_scrollbar |
select-list.ts |
selection list | select / command palette |
settings-list.ts |
settings rows | settings panel (940 × 600) |
loader.ts, cancellable-loader.ts |
loading | spinner / skeleton; cancellable work needs a visible cancel |
image.ts |
terminal image | <img> |
spacer.ts |
spacer | spacer |
alt-screen-flash.ts |
full-screen flash | toast |
The TUI status line is a configurable item list (packages/tui/docs/status-line-config.md,
key tui.statusLine; array order is display order). Mirror the item catalogue and its
ordering semantics:
| Item ID | Display | Web UI placement |
|---|---|---|
current-dir |
working directory | left of the status bar |
session-title |
session title | top bar |
git-branch |
current branch | status bar |
review-link |
linked PR/MR | status bar (clickable) |
plan-mode |
plan mode | status bar badge |
approval-mode |
permission / approval mode | status bar badge |
model, model-with-reasoning |
model and reasoning level | above the composer, or the status bar |
context-window |
context capacity | ring or bar |
subagent |
subagent indicator | status bar |
token-quota |
quota / plan | status bar |
build-mode |
[V] machine-readable protocol |
automation mode; owns the entire line |
Preserve the rules: unlisted items are hidden; an empty array hides the line; duplicate IDs
keep the first; unknown IDs are ignored silently; build-mode owns the whole line.
The TUI's 40 slash commands (packages/tui/src/tui/commands/catalog.ts) map to command
palette entries, keeping their grouping:
sessions sessions history fork rewind rename archive transcript copy retry edit
tasks tasks queue stop steer goal plan decision
permissions permission permissions allow always deny
config config settings statusline hotkeys provider plugins add-dir reload
account login logout checkin
feedback feedback btw
other update changelog review parent
| TUI source | Feature | Web UI |
|---|---|---|
tui/agent-team/{model,panel,summary}.ts |
multi-agent team | agent-team panel |
tui/background-work/panel.ts |
background work | background tasks drawer |
tui/automation/* |
turn results | task result cards + status store |
tui/controller/* |
session, delegation, projection | client state (see packages/webui/docs/ARCHITECTURE.md) |
Status: the migration is complete. The three columns on the left of the table below are kept as the historical record of what the Web UI shipped before the migration. They are marked retired and no longer describe the running product.
packages/webui/webapp/styles/tokens.css is now the desktop's own stylesheet, copied
verbatim. Its header states this, and states that it must not be hand-edited — regenerate it
from the extracted upstream sheet. The retired "Ink & Paper" v3 palette it replaced — neutral
surfaces, a near-black / near-white accent, greys for --success and --warning — is gone:
Ink & Paper has zero hits in the stylesheet, and so do --success, --warning, --danger
and var(--accent) across the whole of webapp/.
Under this document's authority rule, the desktop's palette is the Web UI's. The table below is the migration that was carried out: retired Web UI token → desktop token → the value adopted.
| Retired Web UI token | Light (retired) | Dark (retired) | Desktop token | Light | Dark |
|---|---|---|---|---|---|
--bg |
#fafafa |
#0b0b0c |
--bg_default_primary |
#fff |
#171717 |
--bg-elevated |
#ffffff |
#141416 |
--bg_default_primary_elevated |
#fff |
#1c1c1c |
--bg-sidebar |
#f4f4f5 |
#101012 |
--bg_default_secondary |
#f5f5f5 |
#1c1c1c |
--bg-hover |
#ededee |
#1c1c1f |
--bg_interaction_secondary_hover |
#0a0a0a14 |
#ffffff12 |
--bg-active |
#e2e2e4 |
#26262a |
--bg_interaction_secondary_press |
#0a0a0a26 |
#ffffff0a |
--bg-input |
#ffffff |
#131315 |
--bg_default_tertiary |
#fff |
#262626 |
--text |
#1a1a1c |
#ececee |
--text_default_primary |
#171717 |
#ededed |
--text-secondary |
#5f5f66 |
#a2a2a8 |
--text_default_secondary |
#666 |
#949494 |
--text-tertiary |
#98989e |
#6d6d74 |
--text_default_tertiary |
#adadad |
#666 |
--border |
#e2e2e4 |
#26262a |
--border_default |
#0a0a0a14 |
#ffffff12 |
--border-light |
#ededee |
#1d1d20 |
--border_light |
#0a0a0a0a |
#ffffff0a |
--accent |
#17171a |
#f4f4f5 |
--text_default_accent |
#0094fc |
#0077d9 |
--accent-hover |
#000000 |
#ffffff |
--text_label_accent_hover |
#3daeff |
#0094fc |
--accent-bg |
#ededee |
rgba(244,244,245,.10) |
--bg_interaction_accent_hover |
#0094fc0a |
#0064ab1a |
--accent-text |
#2a2a2e |
#e4e4e7 |
--text_label_accent_default |
#0094fc |
#0077d9 |
--on-accent |
#ffffff |
#101012 |
--text_label_primary_default |
#fff |
#171717 |
--success |
#75757c |
#9d9da3 |
--text_status_success |
#04b54b |
#009c3d |
--warning |
#4f4f56 |
#c8c8cd |
--text_status_warning |
#f56811 |
#e25507 |
--danger |
#bf5645 |
#cc6b5c |
--text_status_error |
#f73646 |
#e31937 |
--status-on |
#1e9e5a |
#3fbf7f |
--text_status_success |
#04b54b |
#009c3d |
--shadow-sm |
0 1px 2px rgba(0,0,0,.05) |
…0.35 |
.shadow-s1 |
0 4px 16px #0000000f |
same |
--shadow-md |
0 4px 14px rgba(0,0,0,.09) |
…0.45 |
.shadow-s1 |
same | same |
--shadow-lg |
0 14px 38px rgba(0,0,0,.14) |
…0.55 |
— | desktop has one elevation | — |
--user-accent |
#17171a |
#f4f4f5 |
--text_default_accent |
#0094fc |
#0077d9 |
--accent-glow |
rgba(23,23,26,.10) |
rgba(244,244,245,.12) |
--bg_interaction_accent_press |
#0094fc14 |
#0064ab26 |
--hairline |
rgba(26,26,28,.08) |
rgba(236,236,238,.08) |
--border_default |
#0a0a0a14 |
#ffffff12 |
--font-mono |
ui-monospace, "Cascadia Code", … |
same | --mcode-font-family-code |
Hack, ui-monospace, … |
same |
--radius-sm |
6px |
6px |
--radius_8 |
8px |
8px |
--radius-md |
10px |
10px |
--radius_12 |
12px |
12px |
--radius-lg |
14px |
14px |
--radius_16 |
16px |
16px |
The four rows in bold are the visible identity change: the accent becomes brand blue instead of near-black, and success / warning / danger become real colours instead of greys. Everything else is a value refinement that keeps the layout intact.
Postscript — the right-hand columns are what the product runs on. Every desktop token in
the table is defined in tokens.css with both theme values, and every value matches:
| Adopted token | tokens.css |
Light resolves to | Dark resolves to |
|---|---|---|---|
--text_default_accent |
L396 / L649 | --blue_400 #0094fc |
--blue_500 #0077d9 |
--text_status_success |
L448 / L701 | --green_400 #04b54b |
--green_500 #009c3d |
--text_status_warning |
L449 / L702 | --orange_400 #f56811 |
--orange_500 #e25507 |
--text_status_error |
L447 / L700 | --red_400 #f73646 |
--red_500 #e31937 |
--bg_interaction_accent_hover |
L293 | #0094fc0a |
— |
--bg_interaction_accent_press |
L295 / L539 | #0094fc14 |
#0064ab26 |
The alias step in §12.1 (keep the old names for one release, then remove them) was not needed:
the stylesheet was replaced wholesale rather than repointed rule by rule, so the retired names
left with it. --font-mono is the one row whose token is not in tokens.css — the
--mcode-font-family-* families ship in webapp/styles/desktop-typography.css, and
tokens.css carries no font-family at all.
-
Add the primitive scale (§3) and the scales (§4) to
main.cssunder:root. -
Add the semantic tokens (§5) with both theme values.
-
Repoint existing component rules from the old names to the semantic names. The plan was to keep the old names as aliases for one release:
:root[data-theme="light"] { --bg: var(--bg_default_primary); --text: var(--text_default_primary); --accent: var(--text_default_accent); --success: var(--text_status_success); /* … */ }
This did not happen. The stylesheet was replaced wholesale by the desktop's own, so there was never a window in which both sets of names were live, and steps 4 and 6 (adopt the typography scale, remove the aliases) had no alias set to act on. The typography scale and the
--code-theme-*block theme did land, indesktop-typography.cssandtokens.cssrespectively.
- The theme mechanism, in the form the desktop uses: a
light/darkclass on<html>(written by the inline bootstrap inapp/layout.tsx, pluscomponents/appearance-sync.tsxfor liveprefers-color-schemechanges), with a three-state user choice —light,dark,system— persisted in thewebui:ui:v1:<cid>localStorageenvelope. §7.5's earlierdata-themeattribute andlocalStorage["webui-theme"]key described the pre-migration frontend and no longer exist. - The layout structure and component markup. This is a token and value change.
- The server is bundled, not dependency-free:
scripts/build.mjsproducesdist/webui/server.jsfrompackages/webui/server/bootstrap.jsand inlines the workspace imports. The frontend is a Next.js static export built at build time (webapp/), which changes what the CSS is compiled by — not what it declares. SeeARCHITECTURE.md§7 for the runtime/build split and the tiered reuse policy.
- The primitive scale, radius, size, and spacing tokens exist under
:root. - Application rules reference semantic tokens only —
grepfor bare hex and for direct--blue_*/--gray_*references outside the token definitions returns nothing. - Every semantic token defines both light and dark.
- Every interactive component covers
default / hover / press / inactive / selected. - Overlays remap
--bg_*to the*_elevatedvariants (§5.4). - Borders use translucent
--border_default, not solid greys. - The accent is the brand blue, not a near-black or near-white value.
-
--success/--warning/--dangerare real semantic colours. - The Chinese locale uses the taller input area (146px) and the English locale 110px.
- The chat area sizes with container query units, reading width 736px.
- Code blocks use
--code-theme-*, not a third-party default theme. - Mixed Chinese/English text falls back to
HarmonyOS Sans SC/PingFang SC. - Status line items follow the TUI ordering, hiding, and exclusivity rules.
- The command palette covers the TUI's 40 slash commands.
-
style.colorSchemeis set alongside thelight/darkclass on<html>. -
data-mavis-surface="web"is declared onhtml.
| Family | Count | Notes |
|---|---|---|
| Primitive scale | 136 | 8 families × 13 steps + violet_500 |
--bg_* |
61 | default / grouped / interaction / status / reference |
--text_* |
64 | default / label / status / reference |
--icon_* |
54 | default / interaction / status |
--border_* |
16 | |
--utility_* |
5 | |
--opacity_* |
25 | |
--reference_* |
16 | |
--code-theme-* |
35 | syntax highlighting |
--radius_*, --size_*, --spacing_* |
7, 9, 15 | scales |
--md-* |
~30 | Markdown and chat typography |
--mavis-* (runtime) |
~60 | type scale, overlay, search, selection, turn navigator |