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.
Direction C, Calm violet
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.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
primary
accent
success
warning
error
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
soft
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.
Badge
soft
solid
with a leading dot
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.
- 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
Counted on demand, not live.
Messages this month
Premium
A denied read is not a zero.
stat - bare, inside a card that already exists, with a state tone
Match coverage
Expected
Complete
Partial
Absent
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
Success
Warning
Error
With actions
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.
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
Delete account
2. the dialog body - four blocks, in this order
- Display name
- Ada Lovelace
- 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
Deletion did not finish
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
skeleton, inside a loading block
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
| User | Type | Locale | Status | Score |
|---|---|---|---|---|
| ada@example.com | NFINFJ | en | complete | 86 |
| grace@example.com | NTENTJ | it | partial | 41 |
| linus@example.com | SPISTP | de | abandoned | 12 |
| hedy@example.com | SJESFJ | ja | complete | 93 |
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.
| User | Status | Score |
|---|---|---|
| ada@example.com | complete | 86 |
| grace@example.com | partial | 41 |
| linus@example.com | abandoned | 12 |
| hedy@example.com | complete | 93 |
matrix cell - a count whose state is not carried by colour alone
| Kind | Entities | en | it | ja |
|---|---|---|---|---|
| Types | 16/16 | 16 of 16 | 9 of 16 | 0 of 16 |
| Matches | 94/136 | 136 of 136 | 94 of 136 | 0 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
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.
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
primary
accent
success
warning
error
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
soft
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.
Badge
soft
solid
with a leading dot
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.
- 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
Counted on demand, not live.
Messages this month
Premium
A denied read is not a zero.
stat - bare, inside a card that already exists, with a state tone
Match coverage
Expected
Complete
Partial
Absent
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
Success
Warning
Error
With actions
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.
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
Delete account
2. the dialog body - four blocks, in this order
- Display name
- Ada Lovelace
- 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
Deletion did not finish
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
skeleton, inside a loading block
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
| User | Type | Locale | Status | Score |
|---|---|---|---|---|
| ada@example.com | NFINFJ | en | complete | 86 |
| grace@example.com | NTENTJ | it | partial | 41 |
| linus@example.com | SPISTP | de | abandoned | 12 |
| hedy@example.com | SJESFJ | ja | complete | 93 |
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.
| User | Status | Score |
|---|---|---|
| ada@example.com | complete | 86 |
| grace@example.com | partial | 41 |
| linus@example.com | abandoned | 12 |
| hedy@example.com | complete | 93 |
matrix cell - a count whose state is not carried by colour alone
| Kind | Entities | en | it | ja |
|---|---|---|---|---|
| Types | 16/16 | 16 of 16 | 9 of 16 | 0 of 16 |
| Matches | 94/136 | 136 of 136 | 94 of 136 | 0 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
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.