Files
multica/packages/ui/styles/tokens.css
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

272 lines
12 KiB
CSS

/* Multica design tokens — shared across Web + Desktop */
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--color-app-shell: var(--app-shell);
--color-page-canvas: var(--page-canvas);
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
--color-surface-raised: var(--surface-raised);
--color-surface-hover: var(--surface-hover);
--color-surface-selected: var(--surface-selected);
--color-surface-selected-foreground: var(--surface-selected-foreground);
--color-surface-border: var(--surface-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* Type scale — the only font sizes product UI may use.
*
* There was no `--text-*` token here before, so sizes grew ad hoc: 51 distinct
* font sizes across web + desktop, 370 of them written as arbitrary values,
* including half-pixel steps (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px) that
* nobody chose deliberately — they appeared because there was no step to reach
* for. Hierarchy does not come from having more sizes; past a handful, every
* extra size makes the hierarchy blurrier, because two values a pixel apart
* read as "the same, but wrong" rather than as two levels.
*
* The steps are named for the role the text plays, not for a t-shirt size.
* That is what keeps the scale from drifting again: "what is this text for"
* has an answer a reader can check, "is this sm or base" does not.
*
* Six of the ten steps replace Tailwind's default scale and deliberately keep
* that scale's exact size/line-height pairs, so `text-sm` -> `text-body` moves
* nothing on screen. Only the steps that fill the old gaps — micro (11), label
* (13), body-lg (15) — are new sizes; those are the gaps the arbitrary values
* were papering over. Every step carries its own line-height so leading cannot
* fragment the way size did; a `leading-*` utility still wins where a specific
* block needs to override it.
*
* Landing-page display type (rem/clamp, 2.2-6.4rem) is marketing typography on
* a separate ramp and stays out of this scale on purpose. */
@theme {
/* counters, badges, timestamps, overline labels */
--text-micro: 11px;
--text-micro--line-height: 15px;
/* secondary and helper text — was text-xs */
--text-caption: 12px;
--text-caption--line-height: 16px;
/* dense labels: table headers, metadata rows */
--text-label: 13px;
--text-label--line-height: 18px;
/* UI body, the workhorse — was text-sm */
--text-body: 14px;
--text-body--line-height: 20px;
/* roomier body: dialog copy, onboarding prose */
--text-body-lg: 15px;
--text-body-lg--line-height: 22px;
/* small headings — was text-base */
--text-title-sm: 16px;
--text-title-sm--line-height: 24px;
/* section headings — was text-lg */
--text-title: 18px;
--text-title--line-height: 28px;
/* card and dialog titles — was text-xl */
--text-title-lg: 20px;
--text-title-lg--line-height: 28px;
/* page titles — was text-2xl */
--text-display-sm: 24px;
--text-display-sm--line-height: 32px;
/* hero numbers and empty-state headlines — was text-3xl (30px) */
--text-display: 36px;
--text-display--line-height: 40px;
}
:root {
color-scheme: light;
/* Surface system: the app shell is the quiet outer frame; the page canvas
is where lists, boards, and conversations live; surfaces are reserved
for bounded content groups; raised surfaces are ephemeral overlays. */
--app-shell: oklch(0.964435 0.001327 286.375);
--page-canvas: oklch(0.988087 0 0);
--surface: oklch(1 0 0);
--surface-foreground: oklch(0.141 0.005 285.823);
--surface-raised: oklch(1 0 0);
--surface-hover: oklch(0.967 0.001 286.375);
--surface-selected: oklch(0.95 0.002 286.375);
--surface-selected-foreground: oklch(0.141 0.005 285.823);
--surface-border: oklch(0.92 0.004 286.32);
--surface-shadow: 0 1px 2px rgb(15 23 42 / 0.04), 0 1px 1px rgb(15 23 42 / 0.03);
--floating-shadow: 0 16px 40px rgb(15 23 42 / 0.14), 0 3px 10px rgb(15 23 42 / 0.08);
/* The chat launcher is an overlay pinned to the dashboard's bottom-right
corner on every workspace page, so that corner is not available to page
content. Its geometry lives here rather than in the button so that the
clearance is derived from it: anything reaching that corner — a footer's
action buttons, a floating toolbar — reserves --chat-launcher-clearance
and stays correct if the launcher ever moves or changes size. */
--chat-launcher-size: 2.5rem;
--chat-launcher-inset: 0.5rem;
--chat-launcher-clearance: calc(
var(--chat-launcher-size) + var(--chat-launcher-inset) * 2
);
/* Menus (dropdown/context/select/popover) hover just above their trigger,
so they carry a lighter shadow than window-level overlays (dialogs,
sheets), which keep --floating-shadow. */
--menu-shadow: 0 8px 24px rgb(15 23 42 / 0.08), 0 2px 6px rgb(15 23 42 / 0.05);
--background: var(--page-canvas);
--foreground: oklch(0.141 0.005 285.823);
--card: var(--surface);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: var(--surface-raised);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
/* Lightness is pinned by WCAG AA, not by taste: muted text sits on every
light surface we have, and the darkest of them — --sidebar-accent, the
nav hover state — is the binding constraint. At the old 0.552 that pair
was 3.98:1; 0.505 clears 4.5:1 on all of app-shell / page-canvas /
sidebar / sidebar-accent / muted / surface-selected / surface, with the
worst case at 4.88:1. Raise it and the nav labels fail again. Hue and
chroma are unchanged so the neutral ramp keeps its cast. */
--muted-foreground: oklch(0.505 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.945 0.003 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
/* Brand-derived blue gradient (h=255 = brand hue). chart-1 equals brand
so the most important series visually anchors to the product colour;
chart-2..5 step lighter + less saturated so a stacked bar reads
"primary → secondary → tertiary" at a glance instead of fighting
for attention as five equally-weighted greys. */
--chart-1: oklch(0.55 0.16 255);
--chart-2: oklch(0.66 0.13 255);
--chart-3: oklch(0.76 0.10 255);
--chart-4: oklch(0.85 0.06 255);
--chart-5: oklch(0.92 0.03 255);
--radius: 0.625rem;
--sidebar: oklch(0.964435 0.001327 286.375);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.935 0.003 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
/* In-page find (Cmd/Ctrl+F) match tints, painted via the CSS Custom
Highlight API. Bright fill + dark text so matches stay legible in
both themes; the active match steps to orange. */
--find-match: oklch(0.92 0.15 100);
--find-match-active: oklch(0.82 0.17 62);
--find-match-foreground: oklch(0.22 0.03 92);
}
.dark {
color-scheme: dark;
--app-shell: oklch(0.155 0.005 285.823);
--page-canvas: oklch(0.18 0.005 285.823);
--surface: oklch(0.21 0.006 285.885);
--surface-foreground: oklch(0.985 0 0);
--surface-raised: oklch(0.235 0.007 285.885);
--surface-hover: oklch(0.274 0.006 286.033);
--surface-selected: oklch(0.3 0.006 286.033);
--surface-selected-foreground: oklch(0.985 0 0);
--surface-border: oklch(1 0 0 / 10%);
--surface-shadow: 0 1px 2px rgb(0 0 0 / 0.2), 0 1px 1px rgb(0 0 0 / 0.16);
--floating-shadow: 0 20px 48px rgb(0 0 0 / 0.46), 0 4px 12px rgb(0 0 0 / 0.28);
--menu-shadow: 0 10px 28px rgb(0 0 0 / 0.3), 0 2px 8px rgb(0 0 0 / 0.18);
--background: var(--page-canvas);
--foreground: oklch(0.985 0 0);
--card: var(--surface);
--card-foreground: oklch(0.985 0 0);
--popover: var(--surface-raised);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 6%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
/* Dark mode mirrors light mode's "primary → secondary" gradient on the
brand hue, but flips the lightness curve: the most important series
is the brightest (so it pops on the dark background) and trailing
series get progressively darker / less saturated. */
--chart-1: oklch(0.72 0.16 255);
--chart-2: oklch(0.62 0.13 255);
--chart-3: oklch(0.52 0.10 255);
--chart-4: oklch(0.42 0.06 255);
--chart-5: oklch(0.32 0.03 255);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
--scrollbar-track: transparent;
--find-match: oklch(0.82 0.16 100);
--find-match-active: oklch(0.76 0.18 60);
--find-match-foreground: oklch(0.18 0.02 92);
}