<!-- https://getkonjo.com/design/studio/metrics · source: docs/design/studio/metrics.md -->

# Studio metrics

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](../konjo-design-language.md). The laws in the spine
> apply here unless this chapter contradicts them. Studio-specific — see
> [the dialect](dialect.md).

A completeness test on the Students roster found that six of its nine hard defects were the
same hole: mobile's [metrics](../foundations/metrics.md) 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](dialect.md)) |
| 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](charts.md) |
| 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](../foundations/accessibility.md) 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](../product-facts.md). 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](../product-facts.md), 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](../foundations/accessibility.md#text-scaling) 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](tables.md#columns).
- **Five to seven columns**, per [tables](tables.md).
- **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.
