The definitive reference for how Konjo looks and behaves, on both surfaces. Supersedes the visual sections of 2026-06-11-design-refresh-dark-mode-design.md and folds in the Studio brief. Built from a 46-app / 315-screenshot study plus a founder calibration session — the reasoning is in 2026-08-22-design-calibration.md, the reference screens are in reference/.
Kata (2026-09-29). The founder reopened every decision below; the brief is the Kata interview. Where the two disagree, the interview wins. So far that means: red is the action colour and L4 is retired; the budgets below are guidance judged by the reviewer, not a hard count; type is Archivo (display: Archivo ExtraCondensed); cards are 16 with pill buttons; the ninja is the mark and appears beyond onboarding (see imagery).
Agents: start at the design skill, not here. It carries the same laws in operational form and routes to the chapter for whatever you are building. Come back for Part 1 — the laws, which every chapter defers to and which the skill states without arguing for; the rest of this file is the argument, and the index at the bottom is the map.
The one idea
Loudness is a budget, and each screen gets one purchase.
Konjo is loud. Bold athletic, high-contrast, heavy condensed caps — the founder's call, and the right one for a martial-arts product. The failure mode of loud is that everything shouts and therefore nothing does. Nike Run Club is loud, but its information design is trivially simple: one enormous number, three supporting stats, one chart, nothing else. The volume is spent on a single element and everything around it is near-silent.
That is the whole trick, and it is the thing Konjo currently gets wrong. Train home today stacks up to eight containers at identical visual weight under a band that shouts three times. Nothing on it can be second-most-important.
Every rule below is downstream of this one:
| The screen gets | At most | Counted as |
|---|---|---|
| Loud block (hero, inverted, display type) | 1, or none | Instances |
| Filled primary button | 1 | Instances |
| Uppercase label register | 1 | Type tokens |
Kata: these are guidance, not a hard count. The reviewer judges whether a screen has one clear focus; a screen that breaks a line here needs a reason, not a rewrite. Red is no longer budgeted: it is the action colour (see L4).
Everything else is quiet enough to disappear.
Uppercase counts a register, not instances. The hero's statement is display voice, not a
label, and does not count — a hero with a caps eyebrow above a caps statement is legal. What is not
legal is two competing label tokens (eyebrow and a second uppercase style) on one screen.
The loud block and the filled primary are literal counts: one each.
These are ceilings, not quotas. A screen with no loud block, no red, and no filled primary is perfectly legal — an index screen usually has exactly that. "At most one" is the rule; "exactly one" was never the rule and any wording implying it is wrong.
When the subject is chosen by the data, the emphasis goes in a slot — or nowhere. Every budget above assumes a screen with a fixed subject, so the designer picks the loud thing once and it is the same thing on every render. A dashboard, a board, a queue, an inbox and a filtered index are not like that: the most important fact is an uncovered class on Tuesday, three unsigned waivers on Wednesday, and nothing at all on Thursday — and loudness in this system is a property of a component, not of a fact. Two rules follow, and they generalise past the shape that found them:
- Allocate the emphasis to one designated slot, not to a region. One place, always in the same position, that renders only when a stated bar is met. What clears the bar is a product fact — "which costs the dojo more, an uncovered class or an unsigned waiver" is a business answer, not a design one — and where no bar is written down, the slot renders only for facts whose urgency is unambiguous.
- When nothing clears the bar, the screen has no loud element, and that is the right answer. Never manufacture urgency to fill the slot: no "all clear" banner, no green celebration, no region promoted to loud because nothing else was. The absence is the answer to the screen's question.
A shape with no slot — a board, where the urgent thing is one card among forty — spends nothing, and the closing bar of the skill ("if you cannot point at the one loud thing within a second, it isn't finished") is asked only of screens with a fixed subject. Worked in full for cards in the dashboard chapter.
Chrome is exempt. The tab bar persists across every screen, so its active state does not
count against the screen. The same goes for the status bar, the header icon row, and the screen
title — a display title is not the screen's loud block, which is why an index screen ships
one and still spends zero. What counts is the content inside the screen's own scroll view.
Control states are exempt too. A selected chip filled with surface.hero on surface.card, or a
checked-in tile, is a control expressing state — not a loud block. Otherwise the tile grid,
which fills a dozen tiles with ink, would be illegal by its own document.
The user we lose
Design decisions get settled by whoever we'd lose first.
- The white belt in week two. Quits when something makes them feel stupid, or an empty screen doesn't say what to do next.
- The student logging one-handed, sweaty, standing, with 40 seconds.
- The instructor mid-class, 20 students, one free hand. Quits at anything needing a paragraph of reading or more than ~3 taps.
- The member deciding what to sign up for. Browsing camps and tests, weighing cost and travel, not yet committed to anything. Quits when a screen makes them hunt for what is on.
- The owner at a desktop in the evening doing admin they resent. Wants the task done and the tab closed. Quits at anything that makes an evening chore longer.
- The front desk, mid-afternoon, three minutes between walk-ins. Works a queue all day — leads, check-ins, the shop — and is measured on nothing falling through. Quits at a screen that hides which item has been sitting untouched.
- The same owner, the next morning, opening Studio with coffee before anyone arrives. Not resentful — triaging. The question is "what needs me today?", the answer changes every day, and the design's job is to make today's answer findable in seconds without them reading five cards to find it. This persona was missing from the catalog until round 8 specified Studio's Home screen and found there was nobody in it who opens a dashboard.
If a design serves the founder's taste but loses one of these, it loses.
Part 1 — The laws
Non-negotiable. Evidence-backed, no taste involved. An agent may not trade these away for a nicer-looking screen, and a reviewer should reject a diff that breaks one.
L1 · Accessibility floor
| Rule | Value | Source |
|---|---|---|
| Text contrast | ≥ 4.5:1 body, ≥ 3:1 for ≥24px or ≥18.5px bold | WCAG 2.1 SC 1.4.3 |
| Non-text contrast | ≥ 3:1 for anything that identifies a control or its state | WCAG 2.1 SC 1.4.11 |
| Touch target | ≥ 44×44pt for any control | Apple HIG; WCAG 2.5.5 (AAA) |
| Minimum type | ≥ 11pt, ever, anywhere | Apple HIG |
| Text spacing tolerance | No clipping at 1.5× line height, 0.12em letter spacing | WCAG 2.1 SC 1.4.12 |
| Dynamic Type | No fixed-height row containing text | body reaches ~46pt at AX5 |
| Reduce motion | Replace movement with a cross-fade; never just delete the transition | WCAG 2.3.3; MDN |
Contrast is computed, never eyeballed. 2.999:1 fails.
Live contrast failures in the current tree — fix these, don't propagate them:
Ratios are written in the A on B — N.NN:1 form so the doc checker recomputes every one of
them. They were once written as bare numbers, and eight of them had drifted by 0.01 from the
generated table — small, and corrosive, because the one claim this system rests on is that
the prose and the code agree.
| Pair | Ratio | Where it hurts |
|---|---|---|
the old text.quaternary, #BBBBBB on #FFFFFF |
1.91:1 | It was the inactive tab label. 4 of 5 tabs were effectively invisible. |
the old dark text.quaternary, #5A5A60 on #0E0E10 |
2.81:1 | Same, dark. |
the old gold.text, #B8860B on #FFF4D6 |
2.96:1 | The achievement moment — the thing the brand cares most about — was the least readable thing in the app. Fixed: ships #8A6300, and #8A6300 on #FFF4D6 is 4.95:1. |
the old text.tertiary, #888888 on #FFFFFF |
3.54:1 | Failed for body copy; used everywhere as one. |
band.steelSub #777777 on #ECECEC |
3.79:1 | Band is retired (L6), but the tokens live on. |
0.85 white on brand.red |
3.97:1 | Every red-strip subtitle. Alpha, so it is composited rather than recomputed here. |
the old feedback.successText, #1F8A4C on #E6F4EC |
3.85:1 | Every success banner and pill. Missed by both the research and the first critic pass — one checked the colour against white and called it "just under", nobody checked it against the fill it is actually painted on. Fixed to #1A7A42, and #1A7A42 on #E6F4EC is 4.73:1. |
the shipping border.focus on surface.hero |
1.14:1 light, 1.00:1 dark | Found in round 8. Every filled primary button and all ~25 items in Studio's rail had a focus ring nobody could see. Fixed: border.focusOnHero, and border.focusOnHero on surface.hero is 16.86:1 light / 14.72:1 dark. |
L2 · The spacing ramp
0 · 2 · 4 · 6 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64 · 80
Fourteen values — Atlassian's spacing table exactly. 8 is the spine (8/16/24/32/40/48/64/80). 4, 6, 12, 20 are the legal half-steps, with 6 reserved for gaps inside a component (icon to label), not for layout. 2 is for optical correction only — baseline nudges, hairline compensation, badge insets — never a layout gap or page padding.
Why not a strict 8pt grid: only 34% of Konjo's 3,782 existing spacing declarations survive
it, and it kills 12, the single most-used value in the app (858 uses). That is a rewrite
of the app's visual rhythm, not a tokenization. Why not a full 2pt ramp: 4,6,8,10,12,14,16
is a number line, not a scale — seven near-identical choices in the 8–16 band is exactly
what produced an 83% magic-number rate.
This ramp keeps 75% of existing declarations and moves ~1,155. The bulk of the migration is two values: 10 (420 uses) and 14 (338), each moving 2px.
Precedent: IBM Carbon (spacing-01 = 2px) and Atlassian (space.025 = 2px), both of which
state an 8px base and ship 2/4/6 anyway. An earlier draft of this ramp dropped 6 for
tidiness; the adversarial review pointed out that it was citing Atlassian as precedent while
deleting the value Atlassian ships, and that keeping 6 is 24% cheaper to migrate
(1,155 declarations vs 1,522). Keeping it.
The ramp is only 20% of the fix. packages/design-tokens/tokens.ts exposes seven semantic spacing
names and zero numeric steps, so there is nothing to reach for when you need "the gap
between a chip and its icon" — you type a number. The other 80% is primitives that own
their own spacing (Stack, Card, Row, Button), so most screens stop declaring
spacing at all. Ramp and primitives together, or neither works. See
the follow-up plan.
L3 · Separation: tint the page, keep cards white
No shadows. No blur. No glow. No gradients. The law survives — but it stops leaning on the hairline, and it runs in the direction the whole reference corpus runs.
| Need | Do this | Not this |
|---|---|---|
| A card on a page | white surface.card on a tinted surface.page |
a grey card on a white page; a 1px border around white on white |
| A group of rows | 12pt of extra space between groups | a divider line |
| A row boundary in a long list | 1px inset hairline, starting at the text edge | 0.5px full-bleed |
| An interactive control's edge (chip, secondary button, input) | 1px border.control |
a fill that doesn't separate |
| A floating surface (sheet, toast, menu) | scrim behind + 1px hairline | a drop shadow |
Two border tokens, two jobs. border.hairline divides rows inside a list. border.control
draws the edge of something you can tap. The distinction matters because WCAG 1.4.11 treats
them differently: a row divider identifies nothing and is presentational, but a chip's edge is
"visual information needed to identify a component", so it has to actually be visible. A
surface.tag chip on the tinted page is 1.017:1 and is not a control anyone can see.
This is a correction. The first version of this law said the opposite — a
surface.muted#FAFAFA card on a white page. That is arithmetically weaker than the thing it replaced: #FAFAFA on #FFFFFF is 1.044:1, against 1.225:1 for theborder.defaulthairline. It would have made separation worse while claiming to fix it. Deepening the card fill instead doesn't work either — by the time the fill separates properly (#EDEDED, 1.171:1)text.tertiaryon it has fallen to 4.05:1 and fails AA.Inverting solves both at once, and it is what Apple Health, Attio, Notion and Strong all actually ship: a tinted page at 1.119:1 against a white card, with body text sitting on white where
text.tertiaryholds 4.74:1.Dark has to be checked separately, and originally wasn't. The first dark pair (
#18181Ccard on#0E0E10page) was 1.089:1 — weaker than the light pair this law is defended with, and with borders and shadows both banned there was no legal recourse. The card is now#1C1C22: 1.137:1, slightly ahead of light, withtext.tertiarystill at 5.33:1 on it. Publish both ratios whenever this law is restated; a separation law with only one theme's arithmetic is half a law. Social home already tries this ("white cards on a gray page") and the audit found it invisible at phone width — because the grey was #FAFAFA. Same idea, correct value.
Three reasons this is right, beyond taste:
- The old law was already broken 13 times — six shadow/elevation declarations in
src/(PlansLibraryScreendefines two levels, an elevation ramp reinvented locally) and seven gradients in Studio. When a law is routed around every time it binds, the law is wrong. Tonal fills give those cases somewhere legal to go. - The hairline is the least reliable pixel on the device. 356 of 487 border
declarations are
0.5, which vanishes or ghosts at dpr 1 and rounds inconsistently at dpr 3. One hairline value: 1px. - The contrast arithmetic decides it, but read the standard correctly. WCAG 1.4.11's
3:1 applies to boundaries required to identify a control or its state — not to card
grouping, which is presentational. No tonal fill on any surface anywhere reaches 3:1, so
the broad reading would outlaw the founder's own answer by arithmetic and take Apple
Health with it. What the numbers do settle is the relative question: 1.044:1 is
invisible, 1.119:1 is what shipping products use, and a
border.defaulthairline is 1.225:1 but as a hard 1px edge rather than an area — which is why it survives as a row divider and fails as a card boundary.
Material 3 reaches the same conclusion from the other direction: it ships six elevation levels and then instructs designers to prefer tonal elevation, using shadow "only for elements that need more focus."
Two gradient exemptions, and only two: an image scrim (needed anyway for legible text over an event photo), and exactly one brand moment (belt promotion / achievement).
L4 · Red is the action colour
Retired and replaced (Kata, 2026-09-29). L4 was "one red role per screen". The founder retired it: red is now the colour of action. The history of the old law is in the August calibration, round 12.
| Role | Colour |
|---|---|
| Filled primary button, selected segment, checked box, active tab | brand.red fill, brand.onRed label — 5.01:1 in both themes |
| Links and red text | accent.red, per theme — never brand.red as text (4.48:1 on the light page, 3.85:1 on the dark) |
| Everything structural (labels, eyebrows, chevrons, section titles) | text.primary / ink |
| Secondary and destructive actions | an ink outline (border.control) with text.primary label |
| Errors | feedback.errorText — a different red — always with an icon and words, never colour alone |
Destructive is never red. With red meaning "tap here", a red Delete would read as the thing to tap. A destructive action is an ink outline and a plain verb ("Delete event"), behind the confirmation the serious actions chapter requires.
Red still means something. Red on a surface should always be tappable or be the selected state. A red block that does nothing, red decoration, and red body text are still wrong.
L5 · The uppercase budget
Uppercase is a label treatment: ≤ 3 words, ≤ 20 characters, never a sentence.
The evidence cuts both ways and the rule respects both halves:
- For a glanced single word, uppercase wins. NN/g's write-up of the MIT AgeLab study
found lowercase needed 26% more time for accurate reading of an isolated glanced
word, and condensed needed 11.2% more than regular.
LIVE NOWon a phone held by a sweaty student is exactly that task. - For anything read as a sentence, uppercase loses badly. Reading speed drops 10–20% (Tinker 1955; replicated 2019 at >13%). Dyslexic readers slow a further 13–18%. Readers 55+ were 29% more likely to misunderstand terms set in capitals (Arbel & Toler, 2020).
So: one uppercase register per screen, on labels only. Konjo currently ships five
(eyebrow 11/800, sectionLabel 12/500, bandLabel 16/900, bandLabelLead 18/900, tri-button
labels 10/800) — uppercase used as ambient light instead of a spotlight.
eyebrow is the uppercase register, and it is the only one. label — buttons, chips,
badges — is sentence case. The single exception is a CTA inside the hero block, which may
take the hero's display voice, because the hero is one composition with one voice: eyebrow,
statement and CTA read as a single utterance. Outside the hero, a button says "Check in", not
"CHECK IN".
Always via textTransform, never by typing capitals into the string. VoiceOver reads a
literal "ADD" as "A. D. D."; a textTransform'd "Add" reads correctly.
L6 · The diagonal band is retired
DiagonalBand and the band.* tokens are deprecated. The −2° skew goes; the voice
survives — full-bleed ink block, uppercase 800–900 letterspaced label, live subtitle, red
arrow, all at skew 0.
Why, specifically:
- It only reads as intentional when ≥2 strips stack. Events home ships one and it reads as a crooked black rectangle.
- It costs unpredictable vertical space. Overhang is
(width/2)·tan 2°computed at runtime: 6.8pt on a 390pt phone, 22.3pt at 1280pt web — the same component reserves 3.3× more dead space on desktop. This is why Events has ~90pt of nothing under it. - It cannot hold data. One
numberOfLines={1}uppercase label (long event names silently clip) and one 12px subtitle at 3.79–3.97:1. Every actual payload — the countdown, the class name, "1 video awaiting feedback" — lives in the illegible half. - It cannot be nested, cropped, or reused inside a list or card, so it duplicates the destination it points at. Events shows the same event three times in one viewport; Learn twice.
- It forces negative horizontal margins on every host, which already needed a documented
contract (
bandWrapinEventsHomeScreenexists purely to restore padding the band cancels). - Rotated text cannot use sub-pixel antialiasing — the skew softens every glyph edge.
Squaring it deletes SKEW_DEG, SKEW_TAN, BAND_TOP_GAP, BAND_BOTTOM_GAP and the
runtime width maths in one commit.
L7 · Motion
| Token | Duration | Use |
|---|---|---|
instant |
100ms | Press feedback, checkbox, toggle |
quick |
150ms | Chip select, small expansion |
base |
200ms | Default transition |
enter |
250ms | Sheet in, modal in, screen push |
exit |
180ms | Anything leaving — exits are faster than entrances |
slow |
350ms | Large expansion. The ceiling. |
Easing: cubic-bezier(0.2, 0, 0, 1) standard; cubic-bezier(0, 0, 0, 1) for entering;
cubic-bezier(0.3, 0, 1, 1) for exiting. Prefer ease-out.
Nothing exceeds 400ms except one deliberate celebration (belt promotion), which is opt-out. NN/g: "at 500ms, animations start to feel like a real drag."
Reduce motion is live-listened via AccessibilityInfo and replaces movement with a
cross-fade rather than removing it.
L8 · Never ship
- A screen with no empty state, a mutation with no error path, a list with no loading state.
- Truncation used as a layout strategy. If it doesn't fit, reflow it — don't clip it.
- The same destination twice in one viewport.
- Two controls of equal weight competing to be primary.
- A control under 44pt without
hitSlopto make up the difference. - Text on a gradient or a photograph without a scrim.
- Copy in developer voice.
- A number typed where a token exists.
The rest of the system
The laws above are the part that does not vary. Everything else is a chapter, loaded when you need it. Agents: use the design skill as your entry point — it routes to the right chapter and carries the checklist. This spine is the argument; the chapters are the detail; the skill is the instruction.
Foundations — the raw material
| Chapter | What it settles |
|---|---|
| Colour | The palette, why the hero inverts, the two recurring traps |
| Type | The scale, and two sizes per screen |
| Space | The ramp and its legal half-steps |
| Radius and borders | Two border tokens, two jobs |
| The small metrics | Heights, glyphs, avatars, chips, insets |
| Elevation | Why the app is flat |
| Motion | What moves, how far, and who opts out |
| Iconography | Weight, size, and when a glyph may stand alone |
| Imagery | Photos, avatars, thumbnails, and what happens when they fail |
| Rank | Belts as data — the one Konjo-specific foundation |
| Content format | Dates, numbers, names, counts, truncation |
| Accessibility | Beyond contrast: screen readers, text scaling, reduced motion |
Patterns — the shapes a screen can take
| Chapter | When |
|---|---|
| The tab home | One of the five tab roots |
| The list screen | A collection they came to browse |
| The tile grid | Triage plus one tap per person |
| The detail screen | One record, in full |
| Navigation | Where a screen sits, and how you get back |
| Forms | Anything they type into |
| Pickers and sheets | Choosing among options |
| Serious actions | Legal records, money, anything unrecoverable |
| Buttons | Any action, anywhere |
| Search and filter | Finding one thing among many |
| Empty, loading, error | Every screen, every time |
| Notifications | Push, banners, toasts, badges |
| Celebration | The promotion certificate |
| Progress | Streaks, badges, belts |
Voice, surfaces, and the record
| Chapter | What it settles |
|---|---|
| Voice | The words the app uses, and the ones it never does |
| Brand | Wordmark, icon, splash, store — where Konjo presents itself |
| Studio — the desktop dialect | Where the owner's desk deliberately differs |
| Boards | Columns of cards, one per stage — the container the gesture happens inside |
| Direct manipulation | Drag, drop, reorder — and the keyboard path that is not optional |
| Studio — metrics | Every Studio number, the rail, the pointer model, and what a status means |
| Studio — dashboards | Snapshot cards, and how a screen spends its budgets when the important thing changes daily |
| Studio — tables | The owner's first-class layout |
| Studio — overlays | Drawer, menu, dropdown, transient confirmation — and how a flat system separates a floating surface |
| Studio — charts | What to draw, and the palette that passed validation |
| Studio — public surfaces | The session-less pages a stranger sees, and the two exceptions the storefront is allowed |
| Studio — IA and screen inventory | Every Studio screen and what is on it |
| Anti-patterns | 21 named failures, with the product each was observed in |
| Product facts | The business decisions the design cannot hold — answered once |
| Governance | How a token is added, and how this document changes |
| The completeness test | The standing gate, and why the blind test is not one |
| Where this came from | The research and the calibration rounds |
Generated, never hand-written
| Reference | Generated from |
|---|---|
| Token tables | packages/design-tokens/tokens.ts |
| Component inventory | src/components/ui/index.ts |
Values in those two files are read out of the code on every run and every contrast ratio is
recomputed. If prose anywhere disagrees with them, npm run design:check fails — which is the
only reason this document can be trusted to still be true.