Browse all of Kata

Studio metrics

Source docs/design/studio/metrics.mdMarkdown

On this page

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, never height: 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 44 and its label together. On the web they come apart: height: 46px is 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-height costs 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 for line-clamp on 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.