Mainframe design system
SkillMediaMainframe's own design system, the token scales (type, radius, spacing, color), the dialog/popover/row recipes the app already uses, and the layout traps that make new UI look off. Covers BOTH render trees in packages/ui: v1's warm chrome and the stock shadcn v2 clone, which have different scales and inverted recipes. Use before building or restyling ANY component in packages/ui, and when reviewing UI for design conformance. Generic design skills (ui-ux-pro-max, apple-hig) answer "what looks good"; this answers "what looks like Mainframe".
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Mainframe design system skill
What this skill tells your AI
The instructions your AI receives, as published by qlan-ro/mainframe in .agents/skills/mainframe-design-system/SKILL.md and read by ahel’s review.
packages/ui is Tailwind v4 + shadcn/ui on a hand-built warm-chrome theme. Every scale is defined once
in packages/ui/src/styles/globals.css under @theme inline (~line 793). Nothing here is invented — it is
what the shipped app already does.
Before writing any markup: find the closest existing component and read it. This app has a house style, and the fastest way to violate it is to write generic shadcn from memory.
One tree (the v1/v2 split is history)
Since 2026-08-09 packages/ui holds one render tree: the former src/v2/ folded into src/
and the @v2 alias is gone. The design system is the stock shadcn radix-vega preset
(src/styles/globals.css, imported by app.css) plus src/styles/domain-tokens.css — the
first-class domain palettes (code/term/diff/…), semantic tint tables, and app chrome. There is no
compat layer: the old legacy-bridge.css, every generic mf-* alias, and the whole v1 type scale
are deleted (2026-08-08) — don't reintroduce any of them.
Read references/v2-stock.md first — it carries the live conventions (scales, recipes, dialog
ledger, per-pass verdicts). Historical src/v2/... paths in it now mean src/.... Domain mf-*
utilities remain legitimate where they name domain color (diff tints, syntax, terminals, task/
priority/automation hues) — they resolve via domain-tokens.css and most now DERIVE from the v2
tokens.
The v1 sections further down this file (the 8-rung type scale, compressed spacing, warm-chrome recipes) are retained as HISTORY — useful for reading old commits, wrong for new markup.
Reach for the component before you write the markup
The four most repeated mistakes in this codebase are all the same mistake: building something the library already has.
- Search
components/ui/before writing markup that resembles a primitive. A pill isBadge. A bordered container isCard. A bar showing a percentage isProgress. Hand-rolled versions have shipped for all three, each a near-copy of the primitive's own base classes minus its focus ring and aria. - If a primitive doesn't expose what you need, extend the primitive. You own that file — that is the
shadcn model. Importing raw Radix at a call site to reach an inner element is the tell that a prop is
missing (a
viewportPropsonScrollArea, say). A feature importingradix-uidirectly is a bug. - A stack of overrides on a primitive means you picked the wrong one, or the decision belongs in the
theme. Five call sites overriding
text-muted-foregroundto escape a too-loudforegroundis a token problem, not five styling problems. Strippingp-0 gap-0off a dialog to rebuild its bands is a different component. - Never an arbitrary value where a token could exist.
text-[10px]andtext-[11px]reached eight usages across six files before anyone noticed; no theme change could reach them. If the scale lacks the step you need, add the step.
Ink is a scale, not a colour picker
- One role, one ink. The same thing — a session's name in a row, that same name as a hover-card title — must resolve to one token. Two inks for one role is what makes a popover read as a different app.
- Check what else the token fills.
foregroundis a tooltip's background (bg-foreground), so "make the text softer" silently restyles every tooltip. - A missing semantic gets a token, not the nearest hue. Reaching for
destructiveto mean "warning", orprimaryto mean "connected", encodes the wrong meaning permanently. Add--warning/--success. - A comment that explains a token's behaviour goes stale when the token moves. Fix it in the same pass; a confidently wrong comment costs more than none.
The five rules that catch most of it
- Every text node takes an explicit type rung. There is no default. Unstyled text inherits the 13px body size and silently sits at the wrong rung next to its siblings.
- Every color is a token, and the ink tier is load-bearing. No raw hex, no
bg-slate-800, noshadow-2xl.foregroundandmuted-foregroundare the safe text inks;mf-text-4is ornament and never text. Never stackopacity-*on an ink token, and keep semantic hues (success/warning/priority) on the icon or tint background rather than the text. A contrast test enforces this — seereferences/tokens.md. truncateinside a flex row needsmin-w-0on the shrinkable item.min-width: autois the flex default, so the item refuses to shrink below its content and blows the row out instead of truncating. This is the single most common visual break in this codebase.- A panel's height comes from its container's flex column, never from a magic
max-h-[380px].flex flex-col+max-h-[85vh]on the shell,flex-1 min-h-0 overflow-y-autoon the scrolling body. - Every interactive element gets
data-testid="<surface>-<element>", kebab-case, keyed by domain id — not array index.components/ui/primitives stay passthrough.
Scales
Full tables with every token name: references/tokens.md. The shape of them:
- Type — the v1 rungs are GONE (retired 2026-08-07). Use the stock names, which v2 partly
re-values:
text-xs11px/16 ·text-sm13px/18 (the baseline — an unstyled node lands here) ·text-base16/24 (dialog + pane titles) ·text-lg18/28 (surface titles).text-xland up are phantoms — nothing uses them, so Tailwind never generates them and the class silently does nothing.text-micro/caption/label/body/heading/titleno longer exist anywhere. - Weight rises with the rung.
text-baseand above isfont-bold; below that, medium and semibold are both live (semibold for row titles and active state, medium for secondary).font-normalis effectively unused — muted text gets a muted color, not a lighter weight — andfont-extraboldappears once, on the brand mark. - Spacing — integer steps are COMPRESSED:
p-2is 4px, not 8px (--spacing-1: 2px…--spacing-12: 64px). Fractional steps (p-1.5,py-2.5) are standard Tailwind. Getting this wrong is why ported UI reads twice as airy as the app around it. - Radius —
rounded-xs4 ·sm6 ·md8 (base) ·lg11 ·xl13. Chips and small controls sit at 6–7px, cards at 8–11px, modals at 13px. - Color — shadcn contract (
background/foreground/card/popover/primary/muted/accent/border/…) plus ~90mf-*extensions.accentis the HOVER surface, not the brand — brand isprimary. Three schemes (classic/ocean/velvet) × light/dark all resolve through the same names, so a token is the only way to stay correct in all six. - Window style is a third axis —
unified·split·glasschange panel radius, fill, shadow, and gutter. Never give a feature component its own panel chrome; the shell owns it vialib/appearance/window-style.ts. Style the inside of your surface, not its outer edge.
Three traps worth knowing
-
Integer
size-Non an SVG is a bug. The spacing scale is compressed, sosize-3renders 6px andsize-4renders 8px. Usesize-3.5(14px),size-[12px], or lucide'ssize={12}. Meaningful glyphs sit on a 12/14/16 grid. This shipped app-wide once already — every Button rendered 8px icons. -
Phantom tokens fail silently.
mf-*names only exist if mapped under@theme inline. A typo renders as nothing — no error, no warning. Grepglobals.cssfor the exact--color-mf-…line before using one. -
The
/opacitymodifier DOES work here. 112 shipped uses (bg-primary/10,border-destructive/30). Tailwind v4 compiles it tocolor-mix, which handles the hex/rgba token values fine. Any "never use/opacityon CSS-var colors" guidance you meet is a carryover from the Tailwind-v3packages/app-electronand does not apply here. One real caveat:accent,border, andinputare already alpha colors, so a modifier on those compounds toward invisible.
Recipes
Copy the structure from the named file — do not re-derive it. Details in references/recipes.md.
These are v1's. In src/v2 the dialog and scroll recipes invert — see references/v2-stock.md.
| Surface | Canonical implementation |
|---|---|
| Large panel dialog | features/tasks/TaskEditModal.tsx — hideClose + flex flex-col p-0 gap-0 max-h-[90vh], bordered DialogHeader, scrolling body, action footer |
| Simple list dialog | features/sessions/sidebar/ArchivedSessionsDialog.tsx — default padding, visible DialogTitle, ScrollArea |
| Confirm | components/ui/confirm-dialog.tsx |
| Menu / popover | @v2/components/ui/dropdown-menu — a floating list of actions is ALWAYS a DropdownMenu; see features/git/BranchPopover.tsx |
| Toolbar icon button | layout/MainToolbar.tsx ICON_BTN — 24×28, rounded-[6px], hover:bg-accent |
| Section header / eyebrow | components/ui/section-header.tsx — sentence-case text-xs font-medium text-muted-foreground. Never hand-roll text-micro font-bold uppercase |
| Count / badge | components/ui/count-badge.tsx — capsule-less gray numeral by default; alert is the only filled variant |
| Toast | mfToast from @/lib/toast — not sonner directly |
Dialog defaults you inherit from components/ui/dialog.tsx and must not restate: rounded-xl,
border border-border, bg-popover, shadow-[var(--mf-shadow-modal)], the scrim, the open/close animation.
Re-declaring them is dead class weight; overriding them with raw Tailwind (shadow-2xl, bg-card) breaks
the app's material language.
A dialog needs an exit
hideClose is legitimate — but only when the dialog supplies its own: a footer action row, or a header
close button. A hideClose dialog with neither leaves Escape as the sole exit. Check which you have before
reaching for the prop.
Build the whole state matrix, and only the real states
Two rules carried over from the design prototype, where every component is specified as a grid of states rather than one happy path.
Enumerate the states before you write the markup. For anything with more than one, that means: empty ·
loading · error · populated · resolved; one item vs many; a short string vs one that has to clamp;
disabled and running variants of every control. A component built for the populated case and patched
afterwards is how a panel ends up with a magic max-h and an unstyled error branch.
Don't design an affordance for data that doesn't exist. Before adding a field, a badge, or an elapsed
timer, confirm the type in @qlan-ro/mainframe-types (or the daemon route) actually carries it. Sessions
have exactly three statuses (idle · working · waiting) and no timer; tasks have exactly
open | in_progress | done. Inventing UI for a mechanism the backend lacks is worse than leaving it out —
it reads as a bug forever.
Verifying
Design conformance is not a typecheck. And do not settle a visual question by looking — measure it.
Nearly every "is this bigger / darker / more indented?" question in this codebase has been answered wrong
by eye and right by getComputedStyle. Two glyphs that looked different sizes were both 12px (it was
stroke density); a background that read "pinkish" had chroma 0 (the tint was in a neighbouring token); a
sticky header that looked broken was at y=1186 against a 483px viewport.
The probe: drive the running app with Playwright from inside packages/e2e/, read
getComputedStyle/getBoundingClientRect, print the numbers, then screenshot. Delete the script after.
Resolve colours through a canvas — getComputedStyle returns oklch(…) strings that naive parsing
misreads as rgb.
Before calling UI work done:
- Render it.
pnpm --filter @qlan-ro/mainframe-ui exec vitest run <file>proves it mounts, not that it looks right — for anything visual, run the app (pnpm tauri:devfrompackages/app-tauri, isolated viaDAEMON_PORT+MAINFRAME_DATA_DIR) and look at it, or dispatch thedesign-conformanceagent. - Check it at a narrow width and with a long string in every truncating slot.
- Check both light and dark (the theme toggle is in the toolbar), and at least one non-
glasswindow style — that is where borrowed panel chrome shows up. - Walk the state matrix above, not just the state you happened to build.
- If you touched a color token:
pnpm --filter @qlan-ro/mainframe-ui exec vitest run src/styles/__tests__/contrast.test.ts. - Check compact UI scale (0.92) too, not just normal — it is where undersized type stops being readable.
- Typecheck:
pnpm --filter @qlan-ro/mainframe-ui typecheck.
The visual spec
Most surfaces have an artboard. They live in the claude.ai design project "Mainframe"
(63fecfba-4e43-416e-8ef7-e753512d2a25), readable through the DesignSync MCP tool — list_files, then
get_file. Worth knowing they exist before you invent a layout:
- Per-surface state matrices:
Composer States.html,Chat Cards Review.html,User Message States.html,Popovers Review.html,Tasks Review.html,Viewers Review.html,Workflows Review.html,New Session Review.html,Window States.html,Sidebar Compact.html,Workspace Surfaces.html - Atoms + the full icon inventory:
Primitives.html - Anatomy map — which shadcn/assistant-ui component each element becomes, plus the field-level data
contract per card:
handoff/component-map.md. The most useful single file in the project.
Two caveats. The prototype is hand-built HTML with its own vocabulary (T.*, ACCENT, FS, RADIUS,
window.* module globals) — read it for intent and anatomy, never copy its mechanics into packages/ui.
And Design Tokens Report.html predates the ocean/velvet schemes: it still shows one shared #0a84ff
accent. globals.css is authoritative for values; the artboards are authoritative for layout and states.
The design-conformance agent exists to diff a built component against these artboards — dispatch it after
a port rather than eyeballing.
Related
docs/research/2026-07-11-typography-legibility-audit.md and its findings companion — the
measured basis for the type roles, ink tiers, icon grid, and count-badge treatment above. It shipped as
PR #452 (token re-tints, UI_SCALE_FACTORS 0.92/1.0/1.15, the contrast test, the primitive repairs), so
read it as what the app decided, not as a proposal. Its remaining open items are the P2 tail.
references/v2-stock.md — the live component-tree conventions: the scales, the three extra tokens,
the dialog ledger, and every per-pass verdict. Required reading before any packages/ui markup
(historical src/v2/... paths in it read as src/... since the 2026-08-09 fold).
packages/ui/CLAUDE.md (assistant-ui golden rule, surface model, architecture), the shadcn and
radix-ui-design-system skills for primitive-level questions, ui-ux-pro-max for general visual judgment
when no in-app template exists.
Signals
- GitHub stars
- 43
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
mainframe-design-system- Source
- github.com/qlan-ro/mainframe