The numbers a desktop screen is made of. Mobile has had these since the start; Studio had four adjectives.
Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them. Studio-specific — see the dialect.
A completeness test on the Students roster found that six of its nine hard defects were the
same hole: mobile's metrics is dense and specific — 44pt targets,
insets.top/bottom, control heights, avatar sizes — and Studio's entire metric set was one
sentence of approximations. "~46pt rows", "~10px radius". A tilde is not a value, and 10 is not
on the radius scale at all.
The palette is shared and generated (apps/studio/src/app/tokens.generated.css comes from
the same token source). These are not — they are Studio's, because pointer input and a
1280px viewport are not a phone.
The frame
| Rail | 236px fixed, brand.ink, white text |
| Page gutter | spacing.s32 horizontal |
| Page top | spacing.s32 |
| Page bottom | spacing.s64 — the end of a long page needs air a phone does not |
| Page header → first panel | spacing.s24 |
| Panel padding | spacing.s16 |
| Panel → panel | spacing.s24 |
| Content max width | none. Studio fills 1280–1440; a centred column wastes the width the owner opened the laptop for |
The rail
236px of ink on the left of every Studio screen, and until round 8 it had one line of specification — its width — while carrying ~22 destinations, five group labels, the wordmark, the dojo name, the signed-in user and the sign-out control. Three of those were failing AA and nobody had a rule to check them against.
| Width | 236, fixed, never collapses above 1024 |
| Fill | brand.ink. Not surface.hero — the rail is brand, not a hero block; Studio has no hero blocks (the dialect) |
| Padding | 20 top, 12 horizontal, and a reserved band at the bottom for the assistant launcher |
| Wordmark | 17/900 uppercase, text.onHero, with "Studio" in accent.onHero — never accent.red, which is red for a page and is 3.21:1 on ink |
| Dojo name | caption 13/400 in text.onHeroMuted |
| Group label | eyebrow 11/800/+1.2 in text.onHeroMuted — the same uppercase register as everywhere else |
| Item | 32 tall (6 vertical padding on 14px), radius.small 4, text.onHeroMuted |
| Item, active | text.onHero, surface.heroActive fill, and a 2px brand.red left edge |
| Item, hover | surface.heroHover fill on surface.hero |
| Sub-item | 13px, indented 24, text.onHeroMuted |
| Divider | a low-alpha white hairline. There is no on-ink border token; it is a divider, not a control edge, so it is deliberately under 3:1 |
| Focus ring | 2px border.focusOnHero — border.focus is 1.14:1 on ink and invisible |
The active item's fill is a hint, never the state. surface.heroActive on surface.hero is
1.29:1. What carries it is the red edge — brand.red on surface.hero is 3.77:1, over the 3:1
a non-text indicator owes — plus aria-current="page".
The rail is chrome, the same as mobile's tab bar, so its uppercase group labels and its red active edge do not spend the page's uppercase register or its red role. It persists across every screen; a budget that a persistent element spends is a budget no screen ever has.
A skip link is mandatory on every Studio screen. The rail is ~25 links and it precedes
<main> in DOM order, so without one a keyboard user tabs the entire navigation on every page,
forever (SC 2.4.1).
Pointer
Konjo has an exhaustive touch interaction model — pressed opacity, hitSlop, gesture back,
reduced motion — and had essentially no pointer one. Hover was specified in four places, all
of them added incidentally by the tables chapter and the rail, and brand.redHover /
brand.redActive sat in the palette for a fill most Studio screens never use. Two completeness
rounds, on two different shapes, independently ranked this the largest hole in Studio: a table
happens to be the one place hover was written down, so it is invisible until you build anything
else.
The rule that generalises: a hover fill is a hint, never a state. It confirms which object the click will hit, and the pointer is already sitting on it — so it does not owe 3:1. What it does owe is being computed against the surface it lands on, because every hover token in this palette is near-invisible on the tinted page:
| Object sits on | Hover | Ratio |
|---|---|---|
surface.card — a table row, a panel row, a list inside a card |
surface.muted |
surface.muted on surface.card — 1.04:1 |
surface.page — a card, a board card, anything with no panel under it |
a 1px border.control edge, not a fill |
border.control on surface.page — 3.15:1 |
surface.hero — a rail item, an ink-filled control |
surface.heroHover |
surface.heroHover on surface.hero — 1.21:1 |
An object on the page gets an edge, not a tint. surface.muted on surface.page is 1.07:1
and surface.tag on surface.page is 1.01:1 — there is no fill in this palette that reads on the
page, so a hoverable card announces itself with the control edge it would have had if it were a
control. This is the one place Studio's hover is a border rather than a fill, and it is forced
by arithmetic, not taste.
| Text link hover | the underline appears; the colour does not change |
| Text link active | no separate treatment — the navigation is the feedback |
| Filled red button | brand.redHover, then brand.redActive |
| Secondary button, chip | border darkens to brand.ink; the fill does not change |
| Pill | never — a pill is not a control |
| Card body, on a dashboard | never — a dashboard card is not a link; its rows and its footer link are |
| Card body, when the card is the object | the edge above. A board card, a tile, anything you click or drag as a whole thing. The test is whether the card has one destination or several: one → the card is the control; several → the rows are |
| Transition | motion.duration.instant 100, easing.standard, colour only. Colour-only change is out of scope for reduced motion |
Cursors, because a pointer product that never says this gets four answers from four screens:
| A link, a button, a row that navigates, a chip, a sortable header | pointer |
| Static text, a pill, a card body, a count | default — never pointer on something that does nothing |
| A draggable object at rest / mid-drag | grab / grabbing |
| A disabled control | not-allowed, and it still carries aria-disabled — the cursor is not the message |
| Text you expect people to select — an ID, a URL, an email | text |
Hover is never the only signal. Everything a hover says has to survive a touchscreen, a
keyboard and a screen reader: the row is still a link, the sortable header still carries
aria-sort, the draggable object still has its keyboard equivalent. If removing hover removes
information, the information was in the wrong place.
Controls
Pointer input, so the 44pt touch floor does not apply — but a 32pt minimum does, because a pointer is precise and a wrist is not.
| Button, primary | controlHeight.primary 48 — same as mobile; the page's one filled action stays substantial |
| Button, secondary | controlHeight.dense 36 |
| Input, search, select | controlHeight.dense 36 |
| Chip | controlHeight.chip 32, and no hitSlop — that is a touch remedy |
| Table row | 46 — not "~46" |
| Table header row | controlHeight.dense 36 |
| Avatar | avatarSize.row 32 in a roster or a header, avatarSize.compact 28 in a dense list, avatarSize.profile 88 on a record. Same scale as mobile — Studio's density changes rows, not faces |
| Checkbox | 16pt box at radius.small 4 — at radius.input 8 a 16pt box is a circle and reads as a radio. The click target is the row's full height |
| Icon, inline | iconSize.inline 16 · standalone iconSize.control 24 |
| Belt swatch | swatchSize.roster 16 in a table, swatchSize.ranked 10 in a dense list — with its 1px border.control ring, exactly as on mobile |
Radius is the shared scale — radius.small 4, radius.input 8, radius.card 12,
radius.large 16, radius.sheet 24, radius.pill 999. There is no 10. The dialect's "~10px" predates the scale and is not a
Studio exception.
Type
Studio's base is body 15/400 with lineHeight 22 — the token, not a ratio; 1.45 was
prose and 22/15 is 1.467. The shared scale still applies; the difference is which rows
get used, not which rows exist.
| Role | Token |
|---|---|
| Page title | display 34/800 |
| Panel title | title 22/700 |
| Stat / KPI value | metric 28/800 — never hero, see charts |
| Table header | eyebrow 11/800 caps |
| Table cell, the subject | rowTitle 16/600 |
| Table cell, everything else | body 15/400 |
| Supporting, counts, captions | caption 13/400 |
The subject cell is rowTitle 16/600. A dense table needs its subject column heavier than
its data, and the scale has no bold-at-15 — headerTitle 15/500 is legacy and new work never
picks it. One pixel of extra height on the subject is cheaper than a fifth type token.
Nothing under 11pt, the same floor as mobile, and Studio has no tab bar so it has no exemption.
Status pills
A status pill is not a chip. A chip is a control you click; a pill states a fact and does nothing.
| Height | 24 |
| Padding | 8 horizontal |
| Radius | radius.pill |
| Type | caption 13/400 |
| Fill / text | the matching feedback.* pair |
| Border | none — a pill is not a control, and 3:1 is not owed by a shape that is not tappable |
A pill never carries an action, never has a hover state, and never appears in a column the reader could mistake for something clickable.
What a status means
Picking a colour for "Trial" looks like a design choice and is actually a product judgement wearing a design costume — which is why every Studio table has invented its own answer. It is decided once, here, and a new status vocabulary maps onto this rather than adding to it.
There are four tones and there is no fifth.
| Tone | Fill / text | Contrast | Means |
|---|---|---|---|
| Success | feedback.successFill / feedback.successText |
feedback.successText on feedback.successFill — 4.73:1 light, 6.61:1 dark |
Settled. Nothing is owed and nobody has to act. |
| Warning | feedback.warningFill / feedback.warningText |
feedback.warningText on feedback.warningFill — 5.04:1 light, 8.37:1 dark |
A human has to act soon, and the clock has not run out. |
| Error | feedback.errorFill / feedback.errorText |
feedback.errorText on feedback.errorFill — 5.23:1 light, 5.81:1 dark |
Broken or overdue now. Money is not arriving, or a person is on the mat uncovered. |
| Neutral | surface.tag / text.secondary |
text.secondary on surface.tag — 4.84:1 light, 6.61:1 dark |
Deliberately inactive, closed, or not applicable. Nothing is wrong. |
Neutral is a real tone, not the absence of one. A cancelled membership is not a warning — somebody chose it and it is working as intended. Colouring it amber turns a clean record into an alarm the owner cannot dismiss. This is the single most common mistake in a status column.
text.tertiary is not a pill colour. text.tertiary on surface.tag is 4.31:1 — it fails
AA at pill size, and greying out a status is exactly the instinct that produces it. Neutral
gets text.secondary.
A status is never colour-only. Every pill carries its label, always Sentence case
("Past due", never past_due and never PAST DUE), because
SC 1.4.1 is not satisfied by a fill and because an owner
scanning for the word is faster than one decoding a palette.
The mapping
Every vocabulary Studio ships today. It is not a closed list — a new one maps onto these four tones by the placement procedure below rather than adding a fifth.
| Vocabulary | Success | Warning | Error | Neutral |
|---|---|---|---|---|
| Membership | Active | Trial · Incomplete | Past due | Frozen · Cancelled |
| Waiver | Signed | Link sent · Outdated | Missing | — |
| Style dues | Current | — | Lapsed | Unknown |
| Verification | Verified | Pending | — | — |
| Class coverage | Covered | Sub requested (a later class) | No sub (a class today) | Cancelled |
| Trial (expiry) | — | — | — | Blocked. Whether an expiring trial is a warning or an error depends on what expiry does, which is an open product fact. Until it lands, an expiring trial carries no tone |
A pill's label depends on where it is. In a table the column header supplies the noun, so
the pill is just the value — a WAIVER column carries "Missing". On a card row or a board card
there is no header, so the pill carries the whole phrase — "Waiver missing". Same mapping, same
tone; the words differ because the context does.
Two of those are worth defending. Trial is a warning, not a success — it expires, and the owner's job is to convert it before it does. Missing is an error, not a neutral — an unsigned waiver is a person training uninsured, which is the most expensive fact on the screen; rendering it in grey next to a green "Signed" is the design actively hiding the thing the page exists to surface.
Placing a new status: ask what the owner has to do, and when. Nothing → success. Something, this month → warning. Something, today, or it is already costing them → error. Nothing, because this record is closed → neutral. If the answer is "it depends on the dojo", that is a product fact, not a colour.
A status column does not spend the red role
The red budget (L4 — at most one red role per screen) governs brand.red and
accent.red: the loud, brand-carrying red that says this is the one thing to do here.
feedback.errorText is a different colour doing a different job — it reports a fact about a
record rather than inviting an action — and a table is data, not emphasis. So:
- A status column may render error-tone pills on as many rows as genuinely qualify, and the page's one red role is still available for the primary action.
- The primary action stays
brand.red. Two reds on one screen is fine when one of them is a fill under a label and the other is a button; it is not fine when both are buttons. - A pill never becomes a control to justify the colour. If the owner should act on a row, the row action column is where that lives.
The real constraint on error tone is not the budget, it is calibration: a column where a third of the rows are red has stopped carrying information. If that happens the status vocabulary is wrong — usually a warning has been promoted to an error — and the fix is in the mapping above, not in the palette.
Density
A table row is
min-height: 46, neverheight: 46. 46 is the number you design to: at the default text size, in the widest supported viewport, no cell should wrap. A cell that wraps at default size is a column that is too narrow, and the fix is fewer columns — not a smaller font and not a clipped row.The distinction is not pedantry, and it is the one place Studio has to reason about the no-fixed-height law rather than inherit it. On mobile, Dynamic Type scales
44and its label together. On the web they come apart:height: 46pxis 46 physical pixels at 200% browser zoom and at 200% text-only zoom, but the text inside only grows in the second case — so a fixed row clips its own content for exactly the people who asked for larger text.min-heightcosts nothing when nothing wraps and saves the row when something does. Every Studio row, cell, pill, chip and button states a minimum, never a height. The same goes forline-clampon a cell that carries a person's name: see tables.Five to seven columns, per tables.
A table is dense; a page is not. The gap between panels stays 24 even when the rows are 46, or the screen reads as one undifferentiated grid.