Skip to content

Design system

Every component variant, rendered in light and dark at the same time. Copy the exact classes from here, do not paraphrase them, and never reach for a raw Tailwind palette colour.

canonical

Direction C, Calm violet

These are the approved token values, not placeholders. They are generated by scripts/design/gen-tokens.ts from design/tokens.json, which is itself promoted from design/proposals/violet/, so a colour is changed in the proposal and regenerated, never edited here or in tokens.css. The same file emits the web app's stylesheet and the Flutter theme, which is why all three stay in step.
Lightdata-theme="light"

Semantic tokens

Every colour in the app comes from one of these. Raw Tailwind palette classes do not exist here.

bg

bg-secondary

bg-tertiary

surface

surface-alt

border

border-strong

on-*

primary

on-*

accent

on-*

success

on-*

warning

on-*

error

on-*

info

focus

text tiers

text - primary reading colour

text-secondary - supporting copy

text-tertiary - hints, timestamps, disabled

Family accents

The four type families. Each has a paired on-accent colour for text drawn on top of it.

solid

NFIdealistNTRationalSPExplorerSJGuardian

soft

NFIdealistNTRationalSPExplorerSJGuardian
NFIdealist
NTRational
SPExplorer
SJGuardian

Button

Every size carries a 44x44 touch target from `hitArea44` - a centred before: pseudo-element, never smaller than the control and never smaller than 44px, emitted only under `pointer-coarse` so a mouse never meets the overhang. Do NOT inflate a control to reach the rule; sm is 32px of ink and md is 40, and both already clear it. Reach for the same constant on any control smaller than 44px that is not a native input: ToggleChip, CopyableId, Pagination, Toggle and the theme buttons all use it. Leave 12px between two controls that both carry it, because on touch the overhang owns any gap narrower than itself. A checkbox or a radio is exempt because its label is part of its target, and the matches grid is exempt because 136 cells at 44px stops being a grid - it gets a complete keyboard path instead.

primary

secondary

ghost

danger

icon only

Form controls

Shown on the public profile.

That username is already taken.

Read-only, set at migration.

Publishing refuses anything that is not approved in all 9 locales.

Markdown. Rendered with PvMarkdown in the app.

Checkbox
Goes to every device registered for the segment.
Radio
Reads entitlements/{uid}.premium.
Toggle
Free tier only. Kill switch in appConfig/features.

Badge

soft

neutralprimaryaccentsuccesswarningerrorinfo

solid

neutralprimaryaccentsuccesswarningerrorinfo

with a leading dot

PublishedDraftFailed

Card

CardHeader renders an h2 by default, because a card is normally a top-level section of its page. A card nested inside another section passes level={3}. Both cards here are inside this Section's h2, so both use level={3}; on a real page you would leave the default.

Content version

Currently published to production.

live
Version
2026.08.24-1
Locales
9 / 9 approved

Bare card

12,480

Results computed in the last 30 days

Stat & Fact

The app's only two numeric treatments. Stat is a headline number, text-3xl, in its own card; Fact is a supporting value inside a dl, text-sm. Five pages used to invent their own, across four sizes and four label styles, so nothing on a dashboard read as more important than anything else by design. The single exception is the feedback average, the hero number of its page, which keeps text-4xl.

stat - linked tile, plain tile, and a failed read

Signed-up users

12,480

Counted on demand, not live.

Messages this month

8,213

Premium

blocked by rules

A denied read is not a zero.

stat - bare, inside a card that already exists, with a state tone

Match coverage

Expected

136

Complete

94

Partial

12

Absent

30

fact - a dl row; the parent owns the dl and the grid

Version
2026.08.24-1
Published
3 days ago
24 Aug 2026, 14:02
Locales
9 / 9 approved

Alert

Reserve it for a condition with a state that can change. A permanent, unconditional, undismissable Alert - 'this log cannot be edited', 'nothing reads these yet' - trains an operator to scroll past the banner, and then they scroll past a real one. A fact that is true forever goes in the page description, the card lead, or a text-text-tertiary text-xs note beside the thing it describes.

Info

A one-line explanation of what happened and what to do next.

Success

A one-line explanation of what happened and what to do next.

Modal & dropdown

Shown both as a live trigger and as a static preview, so the panel is visible in a screenshot.

live

static preview

Delete account

This calls POST /api/admin/users/delete, which runs on the Admin SDK behind requireAdmin. It is irreversible.

The user, their results, their friendships and their coach threads are removed. The route writes the adminAudit entry BEFORE the destruction starts, so a run that dies halfway has still left evidence. The real dialog also gates the confirm button on a reason and on typing the uid back; see admin/(guarded)/users/delete-account.tsx.

View profile

Override entitlement

Delete account

Destructive confirmation

No new component: Button variant=danger, Modal, Input with Field's error slot, Alert and Button loading already exist. What this documents is the mandatory COMPOSITION, because an irreversible action reassembled loosely at each call site is how the wrong account gets erased. The live implementation is admin/(guarded)/users/delete-account.tsx.

1. the danger zone - an error Alert whose only action is a danger button

2. the dialog body - four blocks, in this order

Display name
Ada Lovelace
Email
a***@example.com
Created
12 Mar 2024

What this removes

  • Profile, username and friend code
  • Test results (3)
  • Friendships (14)
  • Coach threads (2)

Circles

  • Deleted, no other member is left (1)
  • Handed to another member, this account owns them (2)
  • Kept, this account only leaves them (4)

What stays

Subscriptions are not cancelled here, and the audit entry this writes is permanent.

3. two gates - a reason, and the id typed back

Goes into the audit entry. Name the request.

The button stays disabled until it matches. Paste is NOT blocked - the ceremony here is deliberateness, not secrecy.

That does not match the uid.

4. the confirm button, in all three states

5. terminal panels - the grid is replaced, not annotated

Account deleted

Account dQw4w9WgXcQ is gone, along with its data and its sign-in. The audit entry is 8f2a91c4.

Spinner, skeleton & loading block

Skeleton is aria-hidden, which is right for a grey box and wrong for a page: on its own it makes a load completely silent. Every skeleton surface goes inside LoadingBlock, which adds role=status, aria-busy and an sr-only label from common.loading. Spinner already announces itself through its own label, and passes label={null} inside a control that has a name of its own.

spinner

LoadingLoadingLoading

skeleton, inside a loading block

Loading

Table

The row background lives on the tbody. Never put an opaque background on a Td: cells paint on top of their row, so a cell background hides the hover and the stripe however they are written - which is exactly why no table in this app showed a hover until it was fixed. The stripe and the hover are alpha tints (bg-text/3, bg-text/8) rather than surface tokens, because --surface and --surface-alt are the SAME colour in dark. A head row passes `head` so it gets neither. A numeric column pairs text-right WITH tabular-nums: monospaced digits that are still ragged-right do not line up, which is the whole point of tabular figures. `sort` names the order the rows are already in and sets aria-sort; it does not offer to change it.

standalone - draws its own frame

Recent results
UserTypeLocaleStatusScore
ada@example.comNFINFJencomplete86
grace@example.comNTENTJitpartial41
linus@example.comSPISTPdeabandoned12
hedy@example.comSJESFJjacomplete93

inside a card - `flush`, and NOT inside a CardBody

Recent results

The card is the frame. A rounded box inside a rounded box, inset by the body padding, is the wrong shape and puts the scrollbar where the eye does not look for it.

UserStatusScore
ada@example.comcomplete86
grace@example.compartial41
linus@example.comabandoned12
hedy@example.comcomplete93

matrix cell - a count whose state is not carried by colour alone

KindEntitiesenitja
Types16/1616 of 169 of 160 of 16
Matches94/136136 of 13694 of 1360 of 136

Toggle chip & copyable id

ToggleChip is aria-pressed, not a checkbox: these narrow a set rather than answering a question, and sixteen checkboxes read as a form somebody has to complete. CopyableId is truncated on screen and whole on the clipboard, and it says so out loud both ways - a clipboard refusal needs a secure context and must never look like a success.

toggle chip

copyable id

you

Tabs

The source locale. Everything else is adapted from it.

Pagination

Segmented control and selectable stat

SegmentedControl picks one value out of a handful, all visible: a period, a bar width. A radio group, arrow keys move and select; a disabled option carries its reason as a tooltip. A Stat with onPress becomes a switch for what the page shows; the pressed one takes the primary border and ring.

size=md

size=sm, one disabled

Stat onPress, pressed and not

Time chart

Counts over time, stacked bars or lines. Series take the fixed slots nt, sj, nf, sp (validated in that order for colour blindness in both themes), owned by the four most common values overall so a filter never repaints one; a fifth and beyond fold into Other, never a new hue. A family split passes each family's own tone. Two or more series always get a legend; hover or arrow keys open the per-period card.

kind=bars, one series

kind=bars, split into slots + Other

  • Italian
  • English
  • German
  • Spanish
  • Other

kind=line

  • Italian
  • English
  • German
  • Spanish
  • Other

Empty state

Two treatments, and which one applies is a question about the news, not the layout. EmptyState is for a surface that has nothing to show and needs somebody to act. The all-clear line is for a check that PASSED - 'every entity has all nine locales' is good news, and a dashed box saying it looks like something is missing.

nothing here yet, and somebody has to act

No feedback yet

Feedback submitted from the app lands here, newest first, with the reporter's locale and app version.

all clear - a check that passed

Nothing needs attentionContent is published, the switches are set and no read failed.

all clear - nothing to check yet

Nothing is published, so there is nothing to be missing.

Darkdata-theme="dark"

Semantic tokens

Every colour in the app comes from one of these. Raw Tailwind palette classes do not exist here.

bg

bg-secondary

bg-tertiary

surface

surface-alt

border

border-strong

on-*

primary

on-*

accent

on-*

success

on-*

warning

on-*

error

on-*

info

focus

text tiers

text - primary reading colour

text-secondary - supporting copy

text-tertiary - hints, timestamps, disabled

Family accents

The four type families. Each has a paired on-accent colour for text drawn on top of it.

solid

NFIdealistNTRationalSPExplorerSJGuardian

soft

NFIdealistNTRationalSPExplorerSJGuardian
NFIdealist
NTRational
SPExplorer
SJGuardian

Button

Every size carries a 44x44 touch target from `hitArea44` - a centred before: pseudo-element, never smaller than the control and never smaller than 44px, emitted only under `pointer-coarse` so a mouse never meets the overhang. Do NOT inflate a control to reach the rule; sm is 32px of ink and md is 40, and both already clear it. Reach for the same constant on any control smaller than 44px that is not a native input: ToggleChip, CopyableId, Pagination, Toggle and the theme buttons all use it. Leave 12px between two controls that both carry it, because on touch the overhang owns any gap narrower than itself. A checkbox or a radio is exempt because its label is part of its target, and the matches grid is exempt because 136 cells at 44px stops being a grid - it gets a complete keyboard path instead.

primary

secondary

ghost

danger

icon only

Form controls

Shown on the public profile.

That username is already taken.

Read-only, set at migration.

Publishing refuses anything that is not approved in all 9 locales.

Markdown. Rendered with PvMarkdown in the app.

Checkbox
Goes to every device registered for the segment.
Radio
Reads entitlements/{uid}.premium.
Toggle
Free tier only. Kill switch in appConfig/features.

Badge

soft

neutralprimaryaccentsuccesswarningerrorinfo

solid

neutralprimaryaccentsuccesswarningerrorinfo

with a leading dot

PublishedDraftFailed

Card

CardHeader renders an h2 by default, because a card is normally a top-level section of its page. A card nested inside another section passes level={3}. Both cards here are inside this Section's h2, so both use level={3}; on a real page you would leave the default.

Content version

Currently published to production.

live
Version
2026.08.24-1
Locales
9 / 9 approved

Bare card

12,480

Results computed in the last 30 days

Stat & Fact

The app's only two numeric treatments. Stat is a headline number, text-3xl, in its own card; Fact is a supporting value inside a dl, text-sm. Five pages used to invent their own, across four sizes and four label styles, so nothing on a dashboard read as more important than anything else by design. The single exception is the feedback average, the hero number of its page, which keeps text-4xl.

stat - linked tile, plain tile, and a failed read

Signed-up users

12,480

Counted on demand, not live.

Messages this month

8,213

Premium

blocked by rules

A denied read is not a zero.

stat - bare, inside a card that already exists, with a state tone

Match coverage

Expected

136

Complete

94

Partial

12

Absent

30

fact - a dl row; the parent owns the dl and the grid

Version
2026.08.24-1
Published
3 days ago
24 Aug 2026, 14:02
Locales
9 / 9 approved

Alert

Reserve it for a condition with a state that can change. A permanent, unconditional, undismissable Alert - 'this log cannot be edited', 'nothing reads these yet' - trains an operator to scroll past the banner, and then they scroll past a real one. A fact that is true forever goes in the page description, the card lead, or a text-text-tertiary text-xs note beside the thing it describes.

Info

A one-line explanation of what happened and what to do next.

Success

A one-line explanation of what happened and what to do next.

Modal & dropdown

Shown both as a live trigger and as a static preview, so the panel is visible in a screenshot.

live

static preview

Delete account

This calls POST /api/admin/users/delete, which runs on the Admin SDK behind requireAdmin. It is irreversible.

The user, their results, their friendships and their coach threads are removed. The route writes the adminAudit entry BEFORE the destruction starts, so a run that dies halfway has still left evidence. The real dialog also gates the confirm button on a reason and on typing the uid back; see admin/(guarded)/users/delete-account.tsx.

View profile

Override entitlement

Delete account

Destructive confirmation

No new component: Button variant=danger, Modal, Input with Field's error slot, Alert and Button loading already exist. What this documents is the mandatory COMPOSITION, because an irreversible action reassembled loosely at each call site is how the wrong account gets erased. The live implementation is admin/(guarded)/users/delete-account.tsx.

1. the danger zone - an error Alert whose only action is a danger button

2. the dialog body - four blocks, in this order

Display name
Ada Lovelace
Email
a***@example.com
Created
12 Mar 2024

What this removes

  • Profile, username and friend code
  • Test results (3)
  • Friendships (14)
  • Coach threads (2)

Circles

  • Deleted, no other member is left (1)
  • Handed to another member, this account owns them (2)
  • Kept, this account only leaves them (4)

What stays

Subscriptions are not cancelled here, and the audit entry this writes is permanent.

3. two gates - a reason, and the id typed back

Goes into the audit entry. Name the request.

The button stays disabled until it matches. Paste is NOT blocked - the ceremony here is deliberateness, not secrecy.

That does not match the uid.

4. the confirm button, in all three states

5. terminal panels - the grid is replaced, not annotated

Account deleted

Account dQw4w9WgXcQ is gone, along with its data and its sign-in. The audit entry is 8f2a91c4.

Spinner, skeleton & loading block

Skeleton is aria-hidden, which is right for a grey box and wrong for a page: on its own it makes a load completely silent. Every skeleton surface goes inside LoadingBlock, which adds role=status, aria-busy and an sr-only label from common.loading. Spinner already announces itself through its own label, and passes label={null} inside a control that has a name of its own.

spinner

LoadingLoadingLoading

skeleton, inside a loading block

Loading

Table

The row background lives on the tbody. Never put an opaque background on a Td: cells paint on top of their row, so a cell background hides the hover and the stripe however they are written - which is exactly why no table in this app showed a hover until it was fixed. The stripe and the hover are alpha tints (bg-text/3, bg-text/8) rather than surface tokens, because --surface and --surface-alt are the SAME colour in dark. A head row passes `head` so it gets neither. A numeric column pairs text-right WITH tabular-nums: monospaced digits that are still ragged-right do not line up, which is the whole point of tabular figures. `sort` names the order the rows are already in and sets aria-sort; it does not offer to change it.

standalone - draws its own frame

Recent results
UserTypeLocaleStatusScore
ada@example.comNFINFJencomplete86
grace@example.comNTENTJitpartial41
linus@example.comSPISTPdeabandoned12
hedy@example.comSJESFJjacomplete93

inside a card - `flush`, and NOT inside a CardBody

Recent results

The card is the frame. A rounded box inside a rounded box, inset by the body padding, is the wrong shape and puts the scrollbar where the eye does not look for it.

UserStatusScore
ada@example.comcomplete86
grace@example.compartial41
linus@example.comabandoned12
hedy@example.comcomplete93

matrix cell - a count whose state is not carried by colour alone

KindEntitiesenitja
Types16/1616 of 169 of 160 of 16
Matches94/136136 of 13694 of 1360 of 136

Toggle chip & copyable id

ToggleChip is aria-pressed, not a checkbox: these narrow a set rather than answering a question, and sixteen checkboxes read as a form somebody has to complete. CopyableId is truncated on screen and whole on the clipboard, and it says so out loud both ways - a clipboard refusal needs a secure context and must never look like a success.

toggle chip

copyable id

you

Tabs

The source locale. Everything else is adapted from it.

Pagination

Segmented control and selectable stat

SegmentedControl picks one value out of a handful, all visible: a period, a bar width. A radio group, arrow keys move and select; a disabled option carries its reason as a tooltip. A Stat with onPress becomes a switch for what the page shows; the pressed one takes the primary border and ring.

size=md

size=sm, one disabled

Stat onPress, pressed and not

Time chart

Counts over time, stacked bars or lines. Series take the fixed slots nt, sj, nf, sp (validated in that order for colour blindness in both themes), owned by the four most common values overall so a filter never repaints one; a fifth and beyond fold into Other, never a new hue. A family split passes each family's own tone. Two or more series always get a legend; hover or arrow keys open the per-period card.

kind=bars, one series

kind=bars, split into slots + Other

  • Italian
  • English
  • German
  • Spanish
  • Other

kind=line

  • Italian
  • English
  • German
  • Spanish
  • Other

Empty state

Two treatments, and which one applies is a question about the news, not the layout. EmptyState is for a surface that has nothing to show and needs somebody to act. The all-clear line is for a check that PASSED - 'every entity has all nine locales' is good news, and a dashed box saying it looks like something is missing.

nothing here yet, and somebody has to act

No feedback yet

Feedback submitted from the app lands here, newest first, with the reporter's locale and app version.

all clear - a check that passed

Nothing needs attentionContent is published, the switches are set and no read failed.

all clear - nothing to check yet

Nothing is published, so there is nothing to be missing.