# Kata — the Konjo design system (full text) Index: https://getkonjo.com/design/llms.txt --- Source: https://getkonjo.com/design/start/overview (repo: docs/design/README.md) # Konjo design **Building a screen?** Don't start here — start with [the design skill](../../.claude/skills/design/SKILL.md). It is the operational form of everything in this directory and it routes to the right chapter. This directory is the argument behind the instruction. | | | |---|---| | [konjo-design-language.md](konjo-design-language.md) | The spine: the one idea, who we lose, the eight laws, and the index to every chapter | | [foundations/](foundations/) | Colour, type, space, borders, metrics, elevation, motion, iconography, imagery, rank, content format, accessibility | | [patterns/](patterns/) | Tab home, list, tile grid, detail, forms, pickers, serious actions, buttons, states, celebration, progress | | [voice/](voice/) | The words the app uses | | [studio/](studio/) | The desktop dialect | | [governance.md](governance.md) | How a token is added and how these documents change | | [provenance.md](provenance.md) | Where this came from | | [reference/](reference/) | 44 curated screens, including the Konjo targets | | [2026-09-29-kata-founder-interview.md](2026-09-29-kata-founder-interview.md) | **Kata** — the rebuild brief: fourteen interview rounds that reopened every decision | | [research/2026-09-29-nike-apple-benchmark.md](research/2026-09-29-nike-apple-benchmark.md) | Nike's Podium, Apple's HIG and seventeen brand guides, crosswalked against Konjo | | [2026-08-22-design-calibration.md](2026-08-22-design-calibration.md) | What was asked, chosen, rejected and corrected across sixteen rounds | ## The parts that are generated Values are never hand-written. These three files are read out of the code on every run, with every contrast ratio recomputed: - [Token tables](../../.claude/skills/design/references/tokens.md) ← `packages/design-tokens/tokens.ts` - [Component inventory](../../.claude/skills/design/references/components.md) ← `src/components/ui/index.ts` - `apps/studio/src/app/tokens.generated.css` ← `packages/design-tokens/tokens.ts` ```bash npm run design:sync # regenerate npm run design:check # fail if stale, or if prose disagrees with the code npm run design:gallery # render every token in both themes, and screenshot it ``` `design:check` runs in CI. It is the reason this documentation can be trusted to still be true — see [governance](governance.md) for what it does and does not cover. --- Source: https://getkonjo.com/design/start/language (repo: docs/design/konjo-design-language.md) # Konjo design language > 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](../superpowers/specs/2026-06-11-design-refresh-dark-mode-design.md) > and folds in [the Studio brief](studio/ia-and-inventory.md). > Built from a 46-app / 315-screenshot study plus a founder calibration session — the > reasoning is in [2026-08-22-design-calibration.md](2026-08-22-design-calibration.md), > the reference screens are in [reference/](reference/). > > **Kata (2026-09-29).** The founder reopened every decision below; the brief is > [the Kata interview](2026-09-29-kata-founder-interview.md). 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](foundations/imagery.md)). > > **Agents: start at [the design skill](../../.claude/skills/design/SKILL.md), 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](product-facts.md) — "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](../../.claude/skills/design/SKILL.md) (*"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](studio/dashboard.md). **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](../superpowers/plans/2026-08-22-design-primitives.md). ## 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 the > `border.default` hairline. 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.tertiary` on 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.tertiary` holds **4.74:1**. > > **Dark has to be checked separately, and originally wasn't.** The first dark pair > (`#18181C` card on `#0E0E10` page) 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, with `text.tertiary` still 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: 1. **The old law was already broken 13 times** — six shadow/elevation declarations in `src/` (`PlansLibraryScreen` defines *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. 2. **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.** 3. **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.default` hairline 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](2026-08-22-design-calibration.md), 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](patterns/destructive.md) 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 NOW` on 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 (`bandWrap` in `EventsHomeScreen` exists 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 `hitSlop` to 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](../../.claude/skills/design/SKILL.md) 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](foundations/colour.md) | The palette, why the hero inverts, the two recurring traps | | [Type](foundations/type.md) | The scale, and two sizes per screen | | [Space](foundations/space.md) | The ramp and its legal half-steps | | [Radius and borders](foundations/borders.md) | Two border tokens, two jobs | | [The small metrics](foundations/metrics.md) | Heights, glyphs, avatars, chips, insets | | [Elevation](foundations/elevation.md) | Why the app is flat | | [Motion](foundations/motion.md) | What moves, how far, and who opts out | | [Iconography](foundations/iconography.md) | Weight, size, and when a glyph may stand alone | | [Imagery](foundations/imagery.md) | Photos, avatars, thumbnails, and what happens when they fail | | [Rank](foundations/rank.md) | Belts as data — the one Konjo-specific foundation | | [Content format](foundations/content-format.md) | Dates, numbers, names, counts, truncation | | [Accessibility](foundations/accessibility.md) | Beyond contrast: screen readers, text scaling, reduced motion | ### Patterns — the shapes a screen can take | Chapter | When | |---|---| | [The tab home](patterns/tab-home.md) | One of the five tab roots | | [The list screen](patterns/index-list.md) | A collection they came to browse | | [The tile grid](patterns/tile-grid.md) | Triage plus one tap per person | | [The detail screen](patterns/detail.md) | One record, in full | | [Navigation](patterns/navigation.md) | Where a screen sits, and how you get back | | [Forms](patterns/form.md) | Anything they type into | | [Pickers and sheets](patterns/sheet-picker.md) | Choosing among options | | [Serious actions](patterns/destructive.md) | Legal records, money, anything unrecoverable | | [Buttons](patterns/buttons.md) | Any action, anywhere | | [Search and filter](patterns/search-filter.md) | Finding one thing among many | | [Empty, loading, error](patterns/states.md) | Every screen, every time | | [Notifications](patterns/notifications.md) | Push, banners, toasts, badges | | [Celebration](patterns/celebration.md) | The promotion certificate | | [Progress](patterns/progress.md) | Streaks, badges, belts | ### Voice, surfaces, and the record | Chapter | What it settles | |---|---| | [Voice](voice/voice.md) | The words the app uses, and the ones it never does | | [Brand](brand/identity.md) | Wordmark, icon, splash, store — where Konjo presents itself | | [Studio — the desktop dialect](studio/dialect.md) | Where the owner's desk deliberately differs | | [Boards](patterns/board.md) | Columns of cards, one per stage — the container the gesture happens inside | | [Direct manipulation](patterns/direct-manipulation.md) | Drag, drop, reorder — and the keyboard path that is not optional | | [Studio — metrics](studio/metrics.md) | Every Studio number, the rail, the pointer model, and what a status *means* | | [Studio — dashboards](studio/dashboard.md) | Snapshot cards, and how a screen spends its budgets when the important thing changes daily | | [Studio — tables](studio/tables.md) | The owner's first-class layout | | [Studio — overlays](studio/overlays.md) | Drawer, menu, dropdown, transient confirmation — and how a flat system separates a floating surface | | [Studio — charts](studio/charts.md) | What to draw, and the palette that passed validation | | [Studio — public surfaces](studio/public-surfaces.md) | The session-less pages a stranger sees, and the two exceptions the storefront is allowed | | [Studio — IA and screen inventory](studio/ia-and-inventory.md) | Every Studio screen and what is on it | | [Anti-patterns](../../.claude/skills/design/references/anti-patterns.md) | 21 named failures, with the product each was observed in | | [Product facts](product-facts.md) | The business decisions the design cannot hold — answered once | | [Governance](governance.md) | How a token is added, and how this document changes | | [The completeness test](testing/design-completeness.md) | The standing gate, and why the blind test is not one | | [Where this came from](provenance.md) | The research and the calibration rounds | ### Generated, never hand-written | Reference | Generated from | |---|---| | [Token tables](../../.claude/skills/design/references/tokens.md) | `packages/design-tokens/tokens.ts` | | [Component inventory](../../.claude/skills/design/references/components.md) | `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. --- Source: https://getkonjo.com/design/brand/identity (repo: docs/design/brand/identity.md) # Brand The wordmark, the two colours, the icon, the splash, and the store. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. Everything else in this design language is about the product. This chapter is about the handful of surfaces where Konjo is presenting *itself* — a stranger's first two seconds, an icon on a home screen among forty others, a screenshot in a store listing. Different job, different rules, and until now no rules at all. ## The name **Konjo** is the public brand: user-facing strings, the App Store listing, the marketing site. **Mojo** is the repo codename and appears nowhere a user can see. Some identifiers still carry the pre-rebrand `cuongnhu` slug and scheme; those are plumbing, and the [rebrand checklist](../../business/2026-06-11-konjo-rebrand-checklist.md) tracks them. Cuong Nhu is the launch community, **not the brand**. Konjo is multi-style by design, and no brand surface should imply otherwise — no Cuong Nhu iconography in the icon, the splash, or store art. ## The wordmark `KONJO` set in **Bebas Neue**, all caps, loaded on demand with a system fallback so nothing renders in an unregistered family. [`KonjoWordmark`](../../../src/components/auth/KonjoWordmark.tsx) is the only implementation. Do not re-set the wordmark by hand; a wordmark typed in the app's body font is not the wordmark. - **Bebas Neue is for the wordmark and nothing else.** It is not a display face for headings — the type scale's `statement` and `display` are. One typeface used for two jobs stops being a signature. - **Never inside the app proper.** The wordmark belongs on pre-account surfaces, the icon, the splash and marketing. A logo in a nav bar is a logo doing nothing: the person already knows which app they opened, and it costs the space a screen title would use. - **Clear space** on all sides of at least the cap height. Never crowd it, never place it on a busy photograph, never outline it, never add a shadow. ## The two colours The brand is **red and ink**, and both have precise jobs — see [colour](../foundations/colour.md). - **`brand.red` is the fill.** White on it is 5.01:1. It is the brand's presence, and on product surfaces it is spent once per screen as the red *role*. - **`brand.ink` is the ground.** The near-black behind the wordmark, the Studio rail, the hero block in light mode. **Brand red is not a wash.** A whole screen painted red is not more Konjo, it is less — the red only reads as brand when it is the loudest thing rather than the only thing. This is the single most likely way to get a brand surface wrong. Never introduce a second red. There is one, and belt colours — including a red belt — are the dojo's data rather than the palette; see [rank](../foundations/rank.md). ## The app icon `assets/icon.png`, with `assets/adaptive-icon.png` for Android's masked shape and `assets/notification-icon.png` for the Android status bar. **All three are generated** from [`assets/brand/ninja/head-mark.svg`](../../../assets/brand/ninja/head-mark.svg) by [`scripts/brand/icons.mjs`](../../../scripts/brand/icons.mjs); never edit the PNGs. - **The mark is the ninja head** on brand red: the round hood, the band tilted −6°, the focused eyes and the two flying tails. No words; at 40pt a wordmark is illegible. - **Test it at 16, 29, 40 and 60pt before anything else.** An icon designed at 1024 and never checked small is the most common icon mistake. - **The Android foreground sits inside the 66% safe circle**, tail tips included. The generator frames it there. - **The notification icon is one colour.** Android paints the status-bar icon as a silhouette, so a full-colour icon renders as a blank square. It is white, with the band cut out. - **No screenshot of the UI, no gradient, no drop shadow, no photograph.** The same depth law applies here. - **It does not change with the theme.** One icon. ## The splash `assets/splash-icon.png` on `brand.red`, `resizeMode: contain`. The splash exists to cover a cold start, not to be looked at. Same mark as the icon, centred, on the flat brand fill — no tagline, no version, no loading copy, no spinner. If it is on screen long enough for someone to read something, the problem is the start-up time. Its background is `brand.red`, currently hardcoded in `app.json`. That file cannot import from the token package, so it is one of the few places the value is written by hand — which is exactly why it needs saying here: **if `brand.red` ever changes, `app.json` changes with it.** ## Store screenshots The listing is the only design surface where someone decides whether to install at all. - **Real screens with real-looking data.** Never a mockup of a screen that does not exist, and never a dojo with three students where the app promises a community. - **The first screenshot carries the strongest single idea**, because most people see only that one. - **Caption above the device, in the brand voice** — plain and direct, no exclamation marks, the same [voice](../voice/voice.md) as the app. Text over the device image never. - **Light theme.** Light is the surface Konjo is designed on and the one a stranger should meet first. - **Every screenshot passes the same contrast rules as the app.** A store image is not exempt because it is marketing; it is the first accessibility impression the product makes. - **They go stale.** [apps/site/public/img/screens/](../../../apps/site/public/img/screens/) holds the marketing site's screenshots, captured by [scripts/site/capture-screens.mjs](../../../scripts/site/capture-screens.mjs) — a band strip count that no longer matches, a section that no longer exists. A screenshot of a UI that shipped two redesigns ago is worse than none, because it is a promise the app breaks on first launch. Recapture them at each release gate. ## The mascot The ninja is both the mark and the mascot. Where it may and may not appear is in [imagery](../foundations/imagery.md); its canon and poses are in [the ninja prompt pack](ninja-prompt-pack.md). It never speaks. ## Never - A second red. - Bebas Neue used as a heading face. - The wordmark inside the app's chrome. - Cuong Nhu iconography on a brand surface. - Words in the app icon. - A splash with a tagline or a spinner. - Store screenshots showing screens that no longer exist. --- Source: https://getkonjo.com/design/brand/ninja-prompt-pack (repo: docs/design/brand/ninja-prompt-pack.md) # The Konjo ninja — character sheet and prompt pack **Status:** in use (2026-09-30). **Supersedes** [onboarding-mascot-image-prompts.md](../onboarding-mascot-image-prompts.md), whose canon (red belt, gold #FFD60A, "Mojo") is retired. The ninja is Konjo's logo mark and mascot ([Kata interview](../2026-09-29-kata-founder-interview.md)). This file is how the art is made: ChatGPT generates the drawings, Claude vectorizes and cleans up the chosen ones in Figma, and Rive animates them. Hand-coded SVG was tried and rejected on 2026-09-30 as too crude for a character. It stays the method only for geometric marks. ## How to run it 1. Open a **new** ChatGPT chat, so earlier images don't leak into the style. 2. Attach both reference images from [ninja-reference/](ninja-reference/): - `head-mark.png`: the approved head. Its shape, tilt, eyes and tails are canon. - `current-welcome.png`: today's full-body art, attached **for proportions only**. Its navy hood and straight band are out of date. 3. Paste **Prompt 0 (the character sheet)**. Regenerate until you love it. Everything else is drawn from it. 4. Start each pose in the same chat, and **attach the approved character sheet image again** every time. This is the single biggest lever for consistency. 5. Generate **3–4 versions of each pose**. Download them all at the largest size, as PNG with a transparent background if offered. 6. Upload them all to Claude here. Claude builds a side-by-side, you pick, then Claude vectorizes the winners, cleans them up in Figma with named layers ready for Rive, and wires them into the app. If a result drifts (a new face, a different belt, text appearing), don't fix it with words. Start the pose again from the character sheet. ## The canon (true in every image) | Part | Rule | |---|---| | **Head** | A perfectly round black hood that covers the whole head. The hood's knot sits at the back right, with **two short tails flying behind** as if moving. | | **Eye band** | One cream band across the face, **tilted slightly** (higher on the character's left), with fully rounded ends. | | **Eyes** | Two short dark slits inside the band, **angled inward**: focused and determined, never angry. Emotion comes **only** from the eye shape (and the body). No mouth, no eyebrows outside the band, ever. | | **Gi** | A cream karate gi (#FDF8EB), jacket crossed left over right, sleeves ending at mid-forearm, trousers ending above the ankle. The hood's black shows in a V at the jacket's neck. | | **Hands and feet** | Black (the ninja's suit), simple rounded mitten hands with a visible thumb. Bare-looking rounded black feet. | | **Belt** | **Black belt, always**, tied in a proper square knot, with two tails of equal length. **One small gold tip** (#E8A317) on the end of one tail, the only gold on the character. | | **Proportions** | Friendly athletic: the head about a third of the height, a sturdy torso, short strong limbs. The character should look like it trains. | | **Palette** | Ink #111111 (hood and suit), shadow ink #000000; cream #FDF8EB, cream shadow #E6DCC5; gold #E8A317. Brand red #D62828 appears **only as a background**, never on the character. | | **Never** | Weapons (no swords, stars or nunchaku), speech bubbles or any text, blood or injury, a visible mouth, a different belt colour, gradients, glow, drop shadows, texture, or a scene behind the character. | ## The style (paste-able) > Premium flat vector mascot illustration, the polish of Duolingo's and Headspace's characters. Clean, bold, > closed shapes, like finished Adobe Illustrator artwork. Confident smooth curves, no sketchy or broken lines, > no outlines except where two shapes of the same colour meet. Exactly one flat shadow tone per colour, placed > as a crisp shape (not a gradient), with light from the upper left. Strong clear silhouette that reads at a > small size. Transparent background, character centred with generous margin, square image, no text. ## Prompt 0 — the character sheet > Using the attached head as the exact design of the character's head, create a professional character model > sheet for "the Konjo ninja", a mascot for a martial-arts training app. > > **Character:** a friendly, athletic martial artist. The head is a perfectly round black hood covering the > whole head, with a knot at the back right and two short tails flying behind. A cream eye band crosses the > face, tilted slightly, with rounded ends, and holds two short dark eye slits angled inward for a focused, > determined look. No mouth. It wears a cream karate gi (jacket crossed left over right, sleeves to mid-forearm, > trousers above the ankle) with the black suit showing as a V at the neck, black mitten hands with a thumb, > rounded black feet, and a black belt in a proper square knot whose two equal tails end in one small gold tip > on one tail only. Head about a third of total height, sturdy torso, short strong limbs. > > **Sheet layout:** four full-body views of the same character in a neutral standing pose, on one row, evenly > spaced: front, three-quarter, side profile, back. Below them, a row of five head close-ups showing > expressions made ONLY by changing the eye slits: determined (default), happy (eyes as upward arcs), > surprised (small round eyes), focused (narrow flat slits), proud (closed upward arcs). > > Colours exactly: ink #111111, cream #FDF8EB, cream shadow #E6DCC5, gold #E8A317. [Paste the style paragraph.] ## Poses Start every pose with: *"Using the attached character sheet, draw the Konjo ninja exactly as designed (same head, eye band tilt, eyes, gi, black belt with one gold tip) in this pose:"* Then add the line below, then the style paragraph. | # | Name | Used for | Pose line | |---|---|---|---| | 1 | Welcome | Onboarding hero, sign-in | Standing tall in a warm greeting: one hand raised palm-out beside the head, the other open palm-up at waist height, welcoming. Happy eyes. Slight three-quarter turn. | | 2 | Ready | Default, marketing | Fighting-ready stance: feet shoulder-width apart, knees bent, both fists up in a guard in front of the chin. Determined eyes. Three-quarter view. | | 3 | Kick | App icon (large), splash, marketing | A high side kick, seen from the side: the kicking leg fully extended horizontally at head height with the foot's edge leading, the standing leg slightly bent, arms in a guard, torso leaning away, hood tails and belt tails flying back from the speed. Focused eyes. Correct technique. | | 4 | Bow | Class start, respect moments | A standing bow (rei): heels together, hands flat on the front of the thighs, back straight, bent forward about 30 degrees at the hips, seen from the side. Eyes closed as calm downward arcs. | | 5 | Promotion | Belt promotion celebration | Proudly finishing tying a brand-new belt: both hands pulling the knot tight at the waist, chest up, belt tails flicking outward. Proud eyes. Front view. | | 6 | Jump | Streaks, milestones, share cards | Joyful jump in the air: both fists raised overhead, knees tucked, hood tails and belt tails flying. Happy eyes. | | 7 | Seiza | Loading, rest days, "nothing today" | Kneeling in seiza (sitting on the heels, back straight, hands resting on the thighs), calm and patient. Focused eyes, soft. Three-quarter view. | | 8 | Point | Empty states ("start here") | Standing, pointing confidently to the character's right with one arm fully extended, the other hand on the hip. Determined eyes, head turned toward the point. | | 9 | Oops | Errors, something went wrong | Standing, one hand scratching the back of the hood, the other palm-up in a shrug, slightly off balance. Surprised eyes. Friendly, never sad. | | 10 | Head mark | App icon, favicon, notification | Only the head, facing front with the slight tilt, filling the frame, as a bold logo mark: flat, no shadow tones, pure black and cream only, perfectly centred on a solid #D62828 red square background. | ## After upload Claude will do the following: 1. Compare every candidate side by side on real Konjo surfaces: onboarding, an empty state, the promotion sheet, the app icon at 16–60px, and Android's circle crop. 2. Vectorize the picks, then clean them in Figma. That means merged shapes, the exact palette, one shadow tone, and named layers (`head`, `band`, `eyes`, `tail-l`, `tail-r`, `torso`, `arm-l`, `arm-r`, `leg-l`, `leg-r`, `belt`, `knot`, `belt-tail-l`, `belt-tail-r`) so Rive can rig them. 3. Build the app icon (iOS default, dark and tinted), the Android adaptive and monochrome notification icons, the favicon and the splash, all generated from the vector masters. 4. Rig the character in Rive for the promotion, jump and check-in moments, with reduce-motion fallbacks. The logo head still gets a human illustrator's final pass before the public App Store launch. --- Source: https://getkonjo.com/design/brand/mascot-image-prompts (repo: docs/design/onboarding-mascot-image-prompts.md) # Onboarding Mascot Image Prompts > **Superseded 2026-09-30** by [brand/ninja-prompt-pack.md](brand/ninja-prompt-pack.md). The canon below (red belt, gold #FFD60A, "Mojo") is retired; kept only as history. > **Status (2026-07):** The mascot images shipped and now live as > `assets/onboarding/mascot_{ready,ready2,chop,kick}.webp` (444px WebP; the > 2026-07 onboarding overhaul converted the original ~1.3 MB PNGs and dropped > the unused edge-peek pose along with the habits/feedback preview scenes). > Keep this doc for regenerating or extending the mascot set. These prompts are for replacing the hand-built React/SVG mascot in onboarding with polished image assets while keeping the current pose language: ready stance, habits/stats peek, curriculum strike, and feedback edge peek. The target is a simple, memorable app mascot, not a realistic martial artist illustration. Think: an original 2D martial arts action figure designed for a major consumer app mascot system. It should have the polish and brandability of a big app mascot, but it should not copy any existing brand character. ## Asset Direction Create mascot-only transparent PNGs. Do not bake in onboarding cards, text, labels, icons, phones, charts, or UI panels; the app already renders those around the mascot. Recommended exports: - `assets/onboarding/mascot-ready.png` - `assets/onboarding/mascot-habits-peek.png` - `assets/onboarding/mascot-curriculum-strike.png` - `assets/onboarding/mascot-feedback-peek.png` Generation settings: - Square canvas, 2048 x 2048. - Transparent background. - Character centered with 12-16% padding unless the prompt says to bias left or right. - Full body visible, except the feedback peek variant can be partially cropped by design. - Include a very soft contact shadow under the feet only if it works on transparent PNG. - No text, no letters, no logos, no badges, no watermarks. ## Shared Style Lock Use this style paragraph in every prompt: > Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Use this negative paragraph in every prompt: > Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ## Prompt 0: Character Reference Use this first if ChatGPT image needs a stable character before making the pose set. ```text Create a single full-body character reference image on a transparent background. Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Pose: relaxed neutral ready stance, feet grounded shoulder-width apart, knees slightly bent, hands calmly raised near the torso, as if ready to begin practice. Friendly but disciplined. Canvas: square 2048 x 2048 PNG, transparent background, full body visible, 14% padding, no props. Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ``` ## Prompt 1: Welcome / Ready Stance ```text Create a mascot-only transparent PNG asset for an onboarding screen. Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Pose: composed martial arts ready stance, front three-quarter view. Feet grounded, knees soft, torso upright, both hands in a calm guard near the chest. The character should feel like a trustworthy training companion welcoming the user into practice. Composition: centered full body, readable at small mobile size, strong clean silhouette, subtle contact shadow only. Canvas: square 2048 x 2048 PNG, transparent background, 14% padding. Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ``` ## Prompt 2: Habits / Stats Peek ```text Create a mascot-only transparent PNG asset for an onboarding screen. Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Pose: curious habits-and-progress pose. The character leans slightly from the left toward an invisible stats card on the right, one hand subtly presenting or pointing toward the empty space, the other arm relaxed. Feet remain balanced in a small martial arts stance, as if inviting the user to track their streak and practice consistency. Composition: bias the character slightly left within the square so there is open transparent space on the right for app UI to overlap. Full body visible, readable silhouette, polished and calm. Canvas: square 2048 x 2048 PNG, transparent background, 12% padding. Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ``` ## Prompt 3: Curriculum / Strike ```text Create a mascot-only transparent PNG asset for an onboarding screen. Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Pose: dynamic but controlled side kick to the right, inspired by traditional martial arts curriculum practice. One leg planted firmly, the other leg extended in a clean horizontal kick, hips aligned, upper body balanced, one fist chambered and one hand guarding. The expression and body language should communicate focus and skill, not combat aggression. Composition: full body visible, kick direction to the right, strong clean diagonal energy, enough transparent space around the extended foot so it does not crop. The asset should still read clearly when displayed around 130-150 px tall in a mobile onboarding illustration. Canvas: square 2048 x 2048 PNG, transparent background, 12% padding. Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ``` ## Prompt 4: Feedback / Edge Peek ```text Create a mascot-only transparent PNG asset for an onboarding screen. Original 2D app mascot for a martial arts training app named Mojo. The character should feel like a premium martial arts action figure translated into a flat mobile-app mascot: simple rounded geometric shapes, chunky toy-like limbs, crisp vector edges, sticker-clean silhouette, minimal facial detail, and only 2-3 flat cel-shading shapes. Gender-neutral stylized martial artist wearing an off-white gi, charcoal-black face/undershirt/details, a strong red belt, and one tiny warm gold accent on the belt knot. Brand palette: off-white, charcoal black, red #D62828, gold #FFD60A. Friendly, confident, calm, memorable, and iconic. Polished like a major consumer app mascot, but fully original and not copied from any existing mascot. Pose: encouraging feedback peek. The character is positioned on the right side as if leaning in from just outside an app panel, one hand lightly raised in a supportive coaching gesture and the other arm relaxed. Stance is grounded and balanced, with a subtle martial arts posture. The mood is "nice rep, here is the next thing to improve" rather than silly celebration. Composition: bias the character to the right side of the square. It is okay for the far right elbow or shoulder to feel close to the edge, but keep the face, belt, hands, and feet visible. Leave transparent space on the left for app cards to overlap. Canvas: square 2048 x 2048 PNG, transparent background, 12% padding. Avoid: realism, photorealism, detailed anatomy, realistic human face, realistic fabric texture, many wrinkles, rendered 3D, painterly illustration, concept art, anime, comic-book style, superhero costume, juvenile cartoon, chibi proportions, toddler-like body, goofy expression, mascot animal, weapons, sparring opponent, injury, bruises, aggressive violence, cluttered motion effects, busy background, dojo room background, UI elements, text, letters, logo, badge, watermark, rough sketch, low-quality clipart. ``` ## If It Still Looks Too Real Add this sentence near the top of the prompt: ```text Make it simpler, flatter, and more iconic: use only about 8-12 major shapes total, no realistic fabric folds, no realistic face, no individual fingers, no textured rendering, no detailed lighting, and no painterly shading. ``` ## Acceptance Checklist - Same character across all four images. - Looks credible as martial arts practice, not random action poses. - App-safe at small size: silhouette reads at 130-150 px tall. - Transparent PNG, no baked-in cards or text. - Red belt is visible and consistent. - Simple enough to be a recognizable app mascot, not a detailed illustration. - Polished enough for a real consumer app; not childish, not realistic, not clipart. --- Source: https://getkonjo.com/design/foundations/colour (repo: docs/design/foundations/colour.md) # Colour The palette, why the hero inverts, and the two traps that keep recurring. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. Tokens live in [packages/design-tokens/tokens.ts](../../../packages/design-tokens/tokens.ts) and are consumed **only** through `useTheme()` / `createThemedStyles`. Light and dark are TS-enforced to the same shape. **Light is the design surface** — design in light, verify in dark, ship both. **Values are not written here.** They live in [the generated token tables](../../../.claude/skills/design/references/tokens.md), read out of the code on every run with every contrast ratio recomputed. This table is what each token is *for* — the part a generator cannot know. Duplicating the hexes here is not a hypothetical risk: this table used to carry them, and it was still publishing `border.control` at its superseded value — the one that was 1.37:1 and failed the 3:1 law the token exists to satisfy — while the code had already fixed it. | Token | Its job | |---|---| | `brand.red` | Red as a **fill**. White on it is 5.01:1. | | `accent.red` | Red as **text on an app surface**. The old value was 4.476:1 on the page and failed. | | `accent.onHero` | Red **on the hero block**. Swaps with `accent.red` by theme, because the hero inverts and the page does not. | | `brand.ink` | The brand's black. A *colour*, not a surface — see below. | | `surface.page` | The tinted ground everything sits on. | | `surface.card` | White, sitting on the page. Never the reverse. | | `surface.hero` | The one loud block. **Inverts by theme** — this is the maximally contrasting surface, not "the dark one". | | `surface.tag` | Chip fill. **Card-only**, and never a control on its own. | | `text.primary` | Content. | | `text.secondary` | Supporting text, and the only legal small text **directly on the page**. | | `text.tertiary` | Supporting text **on a card only** — on the tinted page it fails. | | `text.quaternary` | **Not a text colour.** A disabled dot, an unfilled track. Never type. | | `text.onHero` / `text.onHeroMuted` | Type on the hero block. Never a hardcoded hex. | | `border.hairline` | Divides rows *inside* a card. Deliberately below 3:1 — it is not a control edge. | | `border.control` | The edge of something **tappable** — chip, secondary button, input. Clears 3:1 (SC 1.4.11). | | `border.focus` | The 2px focus ring. One of three sanctioned uses of `borderWidth.emphasis` — see [borders](borders.md). | | `gold.text` / `gold.fill` | Achievement. Never chrome. | | `feedback.*` | Error, warning, info, success — each a fill, a border and a text colour, used as a set. | **Red is the action colour (Kata, 2026-09-29).** A filled primary, a link, a selected segment or chip, a checked box and the active tab are red; nothing else is. The August "one red role per screen" law (L4) is retired — see [the spine](../konjo-design-language.md#l4--red-is-the-action-colour). Two consequences this chapter owns: - **Destructive is never red.** It is an ink outline with a plain verb, so "this deletes" never looks like "tap here". - **Errors are not the action red.** `feedback.errorText` is a different red, and an error always carries an icon and words, never colour alone. ### The hero surface inverts by theme, and it has to `brand.ink` #111111 on the dark page #0E0E10 computes to **1.021:1**. Painting the hero with `brand.ink` makes the screen's one loud block *invisible in dark mode* — the formula would delete its own centrepiece. The retired `band.lead` token already knew this and flipped (#131313 light → #F5F5F5 dark); `surface.hero` inherits that behaviour. The hero is not "the dark block". It is **the maximally contrasting surface**: near-black on a light page, near-white on a dark one. Both directions land at ~17:1 against their page. | | Light | Dark | |---|---|---| | `surface.hero on surface.page` | 16.87:1 | 17.22:1 | | `text.onHero on surface.hero` | 18.88:1 | 16.59:1 | | `text.onHeroMuted on surface.hero` | 7.98:1 | 6.11:1 | | `accent.onHero on surface.hero` | 5.91:1 | 5.24:1 | | `border.focusOnHero on surface.hero` | 16.86:1 | 14.72:1 | | `brand.onRed on brand.red` — the CTA's label | 5.00:1 | 5.00:1 (theme-invariant) | **Never put `brand.red` text on the hero** — `brand.red on surface.hero` is 3.77:1 in light. `accent.onHero` exists precisely so nobody has to hardcode a hex to solve this. **And never put `border.focus` on the hero.** `border.focus on surface.hero` is 1.14:1 light and 1.00:1 dark — in dark it is the same hex as the surface, so the ring is not dim, it is absent. `border.focusOnHero` is the ring for an ink-filled control; see [accessibility](accessibility.md#focus). `surface.white` still exists in the code and is **not** a synonym for `surface.card`: in dark it resolves to `#0E0E10`, which is the *page* colour (1.000:1 against the page). A screen root using `surface.white` gets the page; a card using it gets nothing. Migrate to `surface.page` / `surface.card`. `text.quaternary` is **not a text colour**. It is legal for a disabled dot or an unfilled track and nothing else. The inactive tab tint moves to **`text.tertiary`** in both themes — named rather than spelled, because this chapter's own rule is that values are not written here. `text.tertiary on surface.card` is **4.74:1**, which is where body and secondary text live. ### The card-only family Three values are legal on a white card and illegal on the tinted page. They fail quietly, so they are listed together: | Token | On a card | On the page | Use on the page instead | |---|---|---|---| | `text.tertiary` | 4.74:1 ✓ | **4.23:1** ✗ | `text.secondary` (4.76:1) | | `surface.tag` (chip fill) | 1.06:1 — needs `border.control` anyway | **1.017:1** ✗ invisible | a white `surface.card` chip with `border.control` | | `accent.red` **as text** | 5.87:1 ✓ | 5.25:1 ✓ *(after the fix below)* | — | `accent.red` was `#D62828`, identical to `brand.red`, which computes to **4.476:1** as text on the page — a rounding-level AA failure in the token whose entire stated purpose is "red as text on a surface". It is now `#C42020`: 5.25:1 on the page, 5.87:1 on a card. `brand.red` `#D62828` is unchanged and remains the **fill** colour, where white-on-red is 5.01:1. The general rule this family teaches: the labels that organise a screen should be *more* legible than the metadata inside a card, not less. ### Red on ink — the one colour trap `brand.red` #D62828 on `brand.ink` #111111 computes to **3.77:1**. That fails AA for normal text. It passes only for text ≥24px, or ≥18.5px bold. | Red on ink | Use | |---|---| | A red **fill** with white text | Fine — white on `brand.red` is 5.01:1 | | Red **text** or a red glyph on an ink surface | Use `accent.onHero` — **5.92:1** | | Red text at display size (≥24px) on ink | `brand.red` is legal, but prefer `accent.red` anyway | This bites immediately: the hero block is an ink surface with a red eyebrow and a red CTA. The CTA is a red fill (fine). The eyebrow is red text (must be `accent.onHero`). Gold is for achievement moments only. Never in chrome. --- Source: https://getkonjo.com/design/foundations/type (repo: docs/design/foundations/type.md) # Type The scale, what each entry is for, and the two-sizes-per-screen rule. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. **Archivo, at two widths, and Bebas Neue is not one of them.** Kata's typeface is Archivo (OFL), chosen side by side in V1–V2 of [the Kata interview](../2026-09-29-kata-founder-interview.md) over Inter, Geist and Hanken Grotesk: - **Text** — Archivo at normal width, 400–700, for every word that is not display type. - **Display** — Archivo ExtraCondensed (`wdth` 62) at 800/900, tracking 0, for headlines, hero numbers, stats and belt names: the `hero`, `statement`, `metric` and `display` tokens. - **Mono** — `fontFamily.mono`, for a value read character by character or copied (a URL, an ID, an API key). Never body copy, never a label. **Never set a `fontFamily` by hand.** In the app, every `Text` and `TextInput` gets Archivo automatically: a style's `fontWeight` is turned into the matching Archivo family ([`src/theme/brandFont.ts`](../../../src/theme/brandFont.ts)), because React Native picks a custom font by family, not weight. On the web, `fontFamily.body` / `.display` in the token file point at the `next/font` Archivo variable; display adds `font-stretch: 62%`. The display cut is generated by [`scripts/brand/fonts.py`](../../../scripts/brand/fonts.py) because the Expo font package ships normal width only. **What was given up.** The platform face (SF, Roboto) was the one a person had already tuned, needing no network and never flashing. Archivo is bundled, so it needs no network either; it still scales with Dynamic Type; and the app waits for it before first render, so it never flashes. Archivo is wider than SF, so a label that fit before can wrap — that is reflow, which the rules want, never truncation. Bebas Neue is the wordmark only ([brand](../brand/identity.md)). The scale had size, weight, line height, transform and tracking and **no family axis at all**, while Studio's inventory prescribed a monospace copy-link box — so the one pattern that needed a second family could not cite one. Konjo's voice is **heavy condensed caps for display, plain sans for everything else.** Every entry should carry a line height, and 14 of the current 25 do not — the count and the names are regenerated in [the token tables](../../../.claude/skills/design/references/tokens.md). `eyebrow` used to be among them, which mattered because it is the only uppercase register and sits above every section; it now carries one. This is why `ScreenHeader` needs a hardcoded `fontSize: 22` breakpoint hack under 360pt. | Token | Size / weight / tracking | Line height | Use | |---|---|---|---| | `hero` | 64 / 900 / −2.0 | 62 | A single enormous **numeral** — a count, a score, a percentage. Digits only. | | `statement` | 46 / 900 / −1.2 | 46 | The hero block's **words** — a class name, an event name. Uppercase via `textTransform`. | | `display` | 34 / 800 / −0.8 | 38 | Screen title | | `title` | 22 / 700 / −0.3 | 28 | Section and card titles | | `rowTitle` | 16 / 600 / 0 | 22 | List row primary line | | `bodyLarge` | 16 / 400 | 24 | Long-form reading | | `body` | 15 / 400 | 22 | Default | | `caption` | 13 / 400 | 18 | Secondary line, metadata | | `micro` | 12 / 500 | 16 | Dense metadata. Floor for non-label text. | | `eyebrow` | 11 / 800 / +1.2 **uppercase** | 14 | The one uppercase register (L5) | | `label` | 13 / 700 / +0.6 | 16 | Buttons, chips, badges. **Sentence case.** | `caption` is 13, not 12, because 13px is already the most-used raw size in the codebase (166 declarations) and beats the 15px body token. The scale should describe the app that exists. **Two type sizes should carry any given screen's *content*.** Things 3 — the most-praised app in the study for comprehension — uses exactly two on its home screen, and groups by inserting ~12pt of extra space rather than drawing a divider. Chrome is exempt and does not count: the screen title, the hero's `statement` and `eyebrow`, and the tab bar. The rule is about the body of the screen — the rows, cards and tiles below the hero — where two sizes (`rowTitle` + `caption`) should do all the work. A screen whose *content* needs five sizes is a screen that has not decided what matters. **The count is per region, not per screen.** A screen made of one list is two sizes. A screen made of five self-contained cards is two sizes *inside each card* — its card titles, its labels and its footer links are that screen's chrome, exactly as a page title is a single region's. A [Studio dashboard](../studio/dashboard.md) therefore runs `title` for a card, then `rowTitle` + `caption` inside it, plus `eyebrow` and `label` and possibly `metric`, and is legal — because no one region is asking a reader to hold six sizes at once. What the rule forbids is unchanged and is the thing worth checking: **two sizes competing inside one region**. A row with a 16/600 subject and a 15/500 second line has not decided what matters; a card title above a row is a hierarchy, not a competition. **The hero ratio.** When a screen has a hero number, it is *bigger than the screen title*. In the reference set the hero runs roughly 1.8–2× the page title, and the number-to-label ratio is somewhere near 4:1. A "hero" that is 2pt larger than the surrounding text is not a hero. > Ratios from the reference set are **indicative, not measured**. They were read off > downsampled App Store screenshots, several of them keystoned marketing mockups; the > critic pass found individual measurements off by 35% to 3×. Use them for the shape of the > relationship, never as a spec. The specs are Konjo's own tokens above. ## The legacy entries The scale above is what new work uses. The token file ships more — `displayTitle` 28/800, `pageTitle` 28/500, `sessionTitle` 28/500 and `headerTitle` 15/500 among them — and they are live because hundreds of call sites use them, not because they are choices. **A screen title is `display` 34/800.** If you are picking between `display`, `displayTitle`, `pageTitle` and `sessionTitle`, the answer is always `display`; the other three are the same decision made three times before the scale existed. They are not marked `@deprecated` yet because deprecating a token with hundreds of consumers before the migration is scheduled just adds noise to every build — see [governance](../governance.md). --- Source: https://getkonjo.com/design/foundations/space (repo: docs/design/foundations/space.md) # Space The ramp, the legal half-steps, and grouping with space instead of lines. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. The L2 ramp, plus four semantic aliases that must resolve to ramp values: | Alias | Value | Use | |---|---|---| | `page` | 20 | Horizontal page gutter. Unchanged — 521 existing uses. | | `section` | 24 | Between major sections | | `card` | 16 | Inside a card or tile | | `tight` | 8 | Between two things that are related but separate — a control and its neighbour | Grouping is done with space, not lines: **12pt extra between groups.** --- Source: https://getkonjo.com/design/foundations/borders (repo: docs/design/foundations/borders.md) # Radius and borders Two border tokens with two jobs, and the five radii. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. | Token | Value | Use | |---|---|---| | `radius.small` | 4 | Corners on a control under ~20pt — a Studio checkbox, a belt swatch's rounded square | | `radius.input` | 8 | Inputs, select triggers | | `radius.card` | 16 | Cards, tiles (Kata V4; was 12) | | `radius.large` | 16 | Full-width feature blocks | | `radius.sheet` | 24 | Bottom sheets | | `radius.pill` | 999 | **Buttons**, chips, pills, avatars, segmented tracks | **`radius.small` is for small controls only, never a surface.** 8 on a 16pt box is a circle, which is why sub-20pt corners were being typed as a raw `4` — the scale had no step for them. It is not a "tighter card"; a card is 16 at every size. **Buttons and chips are `radius.pill`** — pill buttons with soft 16 cards is the Kata shape language (V4), chosen over crisp 8, balanced 12 and very round 24 cards. Inputs stay `radius.input` 8: a field is a container for typing, not a button. Five values. There are currently **29 distinct raw `borderRadius` values** in the tree, including 42, 44, 66, 19, 11, 7, 5, 3, 1. Everything rounds to the nearest token. **`borderWidth.hairline` 1 is the default and almost always the answer.** `0.5` is banned (356 uses to migrate). **`borderWidth.emphasis` 2 exists and has exactly three uses**, listed here so a fourth has to argue for itself. This paragraph said "one value" for a long time while the token source shipped two, and four chapters said 2px was "the one sanctioned exception to the 1px rule" while two shipped things already used it: | 2px is legal for | Where | |---|---| | The focus ring | `border.focus`, or `border.focusOnHero` on an ink surface — [accessibility](accessibility.md) | | The active navigation edge | Studio's rail item, `brand.red` — [Studio metrics](../studio/metrics.md) | | A dragged object's edge | `border.control`, while a drag is in flight — [direct manipulation](../patterns/direct-manipulation.md) | What the three have in common is that each marks a **transient or navigational state**, not a boundary. A border that is 2px because the designer wanted it heavier is the thing this rule forbids, and it always was. --- Source: https://getkonjo.com/design/foundations/elevation (repo: docs/design/foundations/elevation.md) # Elevation Why the app is flat, and what separates a floating surface instead. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. There is none. Page content is flat (L3). Floating surfaces — bottom sheet, toast, menu, lightbox — separate with **the scrim already behind them plus a 1px hairline**, not a shadow. The six existing shadow/elevation declarations in `src/` are law violations and should be removed as they are touched. --- --- Source: https://getkonjo.com/design/foundations/metrics (repo: docs/design/foundations/metrics.md) # The small metrics Control heights, glyph sizes, avatars, chips, insets — the numbers that are never invented. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them. Everything a screen needs that isn't colour, type or space. Each of these was invented from scratch by an agent testing this document, which is how they got here. | Thing | Value | |---|---| | Icon, inline (arrow, chevron, row glyph) | 16 | | Icon, standalone control (header, toolbar) | 24 | | Icon, status glyph on a tile | 19 | | Avatar, list row | 32 | | Avatar, header | 32, with a 1px `border.hairline` ring | | Avatar, profile | 88 | | Touch target floor | 44 — use `hitSlop` when the visual is smaller | | Screen top inset | `insets.top + spacing.tight` before the first header element | | Scroll inset above the tab bar | `insets.bottom + 80` | | Scroll inset, modal (no tab bar) | `insets.bottom + spacing.section` | | Chip / pill | height `controlHeight.chip` 32 (+`hitSlop`), pad-h 12, `radius.pill`, `label` type, `surface.card` fill + 1px `border.control` | | Skeleton fill | `border.input` — `border.hairline on surface.page` is 1.09:1 and vanishes. Radius follows the shape it stands in for, per [states](../patterns/states.md) | | Belt / rank swatch | `swatchSize.ranked` 10 / `roster` 16 / `subject` 28, always a 1px `border.control` ring | | Pressed opacity | 0.85 | Belt dots carry a **1px `border.control` ring, always — every rank, both themes**. One token solves both ends of the ladder: without it a white belt on a white card is 1.2:1, and a black belt on the dark card is 1.01:1. `border.control` is 3.53:1 on the white card and 3.16:1 on the dark one, so the ring clears SC 1.4.11 either way. A 1.5px ring and an `rgba(255,255,255,.55)` literal were specified here previously; neither is on the ramp. **On the spacing aliases:** this document uses `page` / `section` / `card` / `tight`. The code ships **both**: `page`/`pageHorizontal`, `section`/`sectionGap` and `card`/`componentPadding` are the same numbers under two names. **New work uses the short names**; the long ones stay for the 521 call sites that already have them. Do not invent a third naming scheme. And `detailHorizontal` (24) is not "the padding for detail screens" — it is one screen's deliberate widening. **On the chip fill.** This table used to say `surface.tag` fill with no border. That is the invisible-chip anti-pattern: `surface.tag` is **1.017:1** against the page, so a chip carrying only that fill is not a control anyone can see. A chip is `surface.card` with a 1px `border.control` edge — the same rule [colour](colour.md), [buttons](../patterns/buttons.md), [search and filter](../patterns/search-filter.md) and L3 all state. **On the top of the screen.** The bottom of every screen was specified to the point and the top was not specified at all, which left the offset of the first element — the `✕`, the `‹ Back` — invented per screen. It is `insets.top + spacing.tight`: the safe area already holds the status bar and the notch, so the gap above the first control is small on purpose. A larger gap there reads as an empty band, and on a small phone it costs a row of content before anyone has scrolled. --- Source: https://getkonjo.com/design/foundations/motion (repo: docs/design/foundations/motion.md) # Motion What moves, how far, how fast, and who opts out. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. Konjo is an app people open sweaty, one-handed, with forty seconds before class. Motion earns its place by making a change *legible* — where something came from, what replaced what. It never earns its place by being pleasant. ## The durations, and what each is for Read the numbers from the [token tables](../../../.claude/skills/design/references/tokens.md); this is what they mean. | Token | Use | |---|---| | `instant` | A press state. Colour and opacity only — this is below the threshold where anyone perceives a transition. | | `quick` | A chip selecting, a checkbox, a small in-place change. | | `base` | The default. A sheet's backdrop, a row expanding, a tab's content cross-fading. | | `enter` | Something arriving: a sheet rising, a toast dropping in. | | `exit` | Something leaving. **Deliberately faster than `enter`** — waiting for a thing you already dismissed is the most irritating motion in any app. | | `slow` | Reserved. A deliberate reveal where the delay carries meaning. | **Nothing exceeds 400ms except one celebration.** The [promotion certificate](../patterns/celebration.md) is the single place the app is allowed to take its time, because the moment is the point. ## What is allowed to move - **Things entering and leaving the screen.** Sheets, toasts, banners, list items on first load. - **Things changing size in place.** An accordion, a "show more". - **State on a control.** Press, selection, focus. - **Determinate progress.** A bar that fills as something completes. ## What is not - **Content that is already on screen and did not change.** No entrance animation on scroll, no stagger down a list the user is halfway through. - **Anything decorative.** Pulsing, floating, breathing, drifting. A dojo app is not a screensaver. - **Anything that delays an action.** If a tap has a result, the result is not gated behind an animation finishing. - **Parallax and auto-playing carousels.** Both are vestibular triggers and neither has ever helped anyone find a class. - **Loading spinners under one second.** See [states](../patterns/states.md) — the flash is worse than the wait. ## How it should feel - **Enter with easing out, exit with easing in.** Things arriving decelerate into place; things leaving accelerate away. The reverse reads as broken. - **One thing moves at a time.** If a sheet is rising, the content behind it holds still. - **Motion has a direction and it means something.** A sheet comes from the bottom because that is where it goes back to. A push comes from the trailing edge because back returns there. Arbitrary directions teach nothing. - **Distance is small.** 8–24pt of travel reads as motion; 200pt reads as a journey. The exception is a surface genuinely entering from off-screen. - **Interruptible.** A person who taps again mid-animation gets the new thing, not a queue. ## Reduced motion **This is not optional and it is currently mostly missing** — 14 of the 19 files that animate ignore the setting. Use `useReducedMotion()` from `react-native-reanimated`; `ListFadeIn`, `ReflectPill`, `WizardProgressBar` and `WizardScaffold` already do it correctly. Reduced motion **replaces** movement, it does not delete it: | Normally | Reduced | |---|---| | Slide in from the edge | Cross-fade in place | | Spring / scale | Instant state change | | Progress counting up | Jump to the value | | Scrolling to the first invalid field | Jump to it — focus still moves, nothing is lost | | Certificate reveal | The same certificate, presented statically | Full rationale in [accessibility](accessibility.md). ## The test Watch the screen once at normal speed. If you noticed the animation, it is too long, too far, or should not be there. Then turn reduce motion on and watch again: everything you needed to understand should still be there. --- Source: https://getkonjo.com/design/foundations/iconography (repo: docs/design/foundations/iconography.md) # Iconography Weight, size, colour, and when a glyph may stand alone. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. **In the mobile app, every icon comes from [`src/components/Icon.tsx`](../../../src/components/Icon.tsx), which wraps Phosphor.** No ad-hoc imports from `phosphor-react-native`, no SVG pasted into a component, no emoji. **Studio uses Lucide** ([the dialect](../studio/dialect.md)), and this chapter's rules split accordingly. The sizes (`iconSize` 16 inline / 19 status / 24 standalone), the colour rules, the one-glyph-per-tile budget and the never-decorative rule are set-independent and apply to both. The **weight** system below is Phosphor's — `regular` and `fill`, where fill is a state and never emphasis — and Lucide has no weights: a Studio icon is a single stroke, and a Studio "filled" state is carried by the control around the glyph, not by the glyph. Studio has no `Icon` wrapper of its own, so its imports are direct and its discipline is convention rather than a chokepoint. This paragraph exists because the sentence above it said "every icon" and was false for a third of the product. The wrapper exists so the set stays finite and one name means one glyph everywhere — the alternative is three different "add" icons on three tabs. Need a glyph the wrapper does not export? Add it to the wrapper. That is a two-line change and it is the whole system working. ## Weight Phosphor ships six weights. **Konjo uses two**, and the wrapper already picks between them: `weight ?? (active ? 'fill' : 'regular')`. | Weight | When | |---|---| | `regular` | Everything. The default, and correct unless the glyph is showing state. | | `fill` | The selected tab, the active filter, a completed step, a liked post. | `fill` is a **state**, not emphasis. Filling an icon to make it look important is the same mistake as making a heading red — it spends a budget on decoration. `thin`, `light`, `bold` and `duotone` do not appear in Konjo; mixing weights across one screen reads as two icon sets. ## Size Three sizes, from the `iconSize` scale: | Size | Use | |---|---| | `inline` | Sitting in a line of text — a 16pt glyph beside a 15pt label. Optically matched to the type, never larger. | | `status` | A status glyph on a tile or row. Small enough to stay a marker, big enough to identify. | | `control` | Standing alone as a tappable thing: header actions, tab bar, an icon-only button. Always inside a 44pt target. | Nothing between. A glyph that needs to be bigger than `control` is not an icon, it is illustration — see [imagery](imagery.md). ## Colour - **`text.primary`** by default. An icon is content. - **`text.tertiary`** for a chevron or other pure affordance — it is furniture, and it is card-only (on the tinted page use `text.secondary`). - **Never `text.quaternary`** for anything meaningful. It is legal only for a genuinely disabled glyph or an unfilled track. - **Red only if the icon *is* the screen's one red role.** A red glyph plus a red button is two roles and one has to give. See L4. - On a hero block, `text.onHero` — never a hardcoded hex. An icon carrying meaning is non-text content: it needs **3:1** against its background (SC 1.4.11), which is why a status glyph sits bare on the tile rather than inside a tinted chip at 1.14:1. ## Icon-only controls An icon alone is legible when **all three** hold: 1. It is a platform convention — back, close, search, share, add, more. 2. It sits in chrome, where people expect symbols. 3. It carries an `accessibilityLabel` saying what it does. Otherwise it takes a label. A glyph you invented for "reconcile attendance" teaches nobody, and the person who needed it most is the one who will not tap an unfamiliar square. **Never an icon and a label that say different things.** If the label is "Log a session", the icon is not a stopwatch because stopwatches look nice. ## Icon plus label - Icon leads, label follows, `spacing.tight` between them. - Optically centred on the text baseline, not the box. - One icon per label. A row with a leading glyph, a trailing chevron and a status marker is already at its limit. ## Never - A glyph used as decoration next to a heading. - Two icons meaning the same thing in one feature. - An icon substituting for an empty state. - A brand logo used as an icon. - Emoji anywhere in chrome. See [voice](../voice/voice.md). --- Source: https://getkonjo.com/design/foundations/imagery (repo: docs/design/foundations/imagery.md) # Imagery Photos, avatars, thumbnails, the mascot — and what each looks like when it fails. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. **Chrome carries no imagery.** No photographic headers, no textured backgrounds, no image behind a nav bar. This was a deliberate calibration decision and it has a practical reason: the hero block has to work for a dojo that has never uploaded a picture, and most of them never will. A design that only looks good with photography is a design that looks broken for your first hundred customers. Imagery appears in exactly four places: **user content**, **avatars**, **event and dojo pages**, and **onboarding**. ## The failure case is the design Every image in Konjo is remote, and remote means slow, missing, or 404. Decide what the absence looks like *before* the presence, because the absence is what a new dojo sees. | | Loading | Missing / failed | |---|---|---| | Avatar | The initials fallback — never a spinner | The initials fallback, permanently | | Thumbnail | `surface.tag` block at the final aspect ratio | Same block with an `inline` glyph, no error text | | Event / dojo photo | Reserved space at the final ratio | The section renders without it; nothing collapses | **Reserve the space at the final aspect ratio before the image arrives.** Content that jumps when a picture loads is the most common way a list feels cheap, and it is entirely avoidable. Use [`CachedImage`](../../../src/components/CachedImage.tsx) rather than a bare `Image`, and [`MediaThumbnail`](../../../src/components/ui/MediaThumbnail.tsx) for anything in a grid or feed — it already carries the radius, the ratio and the play overlay. ## Avatars [`Avatar`](../../../src/components/ui/Avatar.tsx) is the only way a person is pictured. - **Sizes are fixed**: 32 in a row and in the header, 88 on a profile. The header's carries a 1px hairline ring so it separates from whatever it sits on. - **Circular, always.** A squared avatar reads as a logo. - **The fallback is initials on a flat fill**, not a silhouette glyph. A generic person icon makes eleven students look like the same student; "AT" does not. The fill is **`surface.muted`** with a 1px **`border.subtle`** ring and **`text.secondary`** initials — named here because "a flat fill" was the whole specification and the next person to build an avatar somewhere new would have picked one. - **Never tint an avatar by belt.** Rank is carried by the [belt dot](rank.md) beside it. Two encodings of the same fact is one too many, and a coloured ring around a photo fights the photo. ## User photos and video - **Posts and coaching clips keep their own aspect ratio**, capped so one tall image cannot own the screen. - **Video routes to Mux**, never to Storage — see the [video pipeline](../../pipelines/video-mux.md). A video thumbnail is a `MediaThumbnail` with the play overlay; it never autoplays in a feed. - **All uploads go through the [upload queue](../../../src/media/uploadQueue.ts)**, never Storage directly from a screen. ## Text on a photograph The short version: don't. If you must — - A **scrim** between the photo and the text, dark enough that the text clears 4.5:1 against the *lightest* pixel it covers, not the average. - The text must still be legible if the image fails to load, which means the scrim is a solid-enough fill on its own. - Never `accent.red` or any red on a photograph. Red is a role; on top of an unpredictable background it is just a colour that vanishes. This is why the hero block is **a solid surface carrying type, never a photograph**. ## The mascot The Konjo ninja is the brand's mark and its mascot: one character, silent, always a black belt ([Kata interview](../2026-09-29-kata-founder-interview.md)). Canon, construction and the pose list are in [the ninja prompt pack](../brand/ninja-prompt-pack.md); the vector masters are in [`assets/brand/ninja/`](../../../assets/brand/ninja/), and the app renders the PNGs that [`scripts/brand/icons.mjs`](../../../scripts/brand/icons.mjs) generates from them. **Where the ninja may appear:** - Onboarding and the auth screens, inline with the wordmark in [`KonjoWordmark`](../../../src/components/auth/KonjoWordmark.tsx). - Marketing, emails and social. - Empty states and error states, in the pose that matches the moment: *point* for "start here", *seiza* for nothing today, *oops* for something that failed. - Celebrations and milestones: *promotion* for a belt, *jump* for a streak or badge. **Where it never appears:** - Chrome: tab bars, headers, navigation. The screen title does that job. - Billing, waivers, safety flags and anything with legal or money consequences. Those moments are serious; a cartoon undercuts them. - As a speaker. **The ninja is silent**: no speech bubbles, no first-person lines, no copy written "in its voice". The words on the screen stay in the app's plain voice. - Recoloured, redrawn by hand, or holding a weapon. In an empty or error state the ninja **accompanies** the message and the one way in; it never replaces them. A screen that shows the ninja and no action is still an empty state with no way in. ## Never - A stock photo of martial arts. Konjo shows *this* dojo or it shows type. - A gradient standing in for an image. - An image that is load-bearing for comprehension with no text alternative. - A decorative image without `accessibilityElementsHidden` — see [accessibility](accessibility.md). - Text baked into an image. It cannot scale, cannot be read aloud, and cannot be translated. --- Source: https://getkonjo.com/design/foundations/rank (repo: docs/design/foundations/rank.md) # Rank Belts as data. The one foundation that is Konjo's and nobody else's. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. Rank is the most identity-defining element in the product. It is the thing a student checks, the thing an instructor confers, and the thing the [certificate](../patterns/celebration.md) exists to celebrate. It is also, until this chapter, the least specified: twenty raw colour constants living outside the theme, two incompatible renderer models, three copies of the data, and eight rank-and-theme combinations that fail contrast outright. ## Rank is a ladder, and the ladder belongs to the dojo **Konjo is multi-style by design.** Cuong Nhu is the launch community, not the model. The schema already reflects this — `styles` and `style_ranks` carry each dojo's ladder, Studio has an authoring surface with an approval flow, and `user_style_ranks` records what a person holds in each art they practise. So the design rule is simple and absolute: > **Never hardcode a rank ladder, a rank name, or a rank colour into a component.** A screen > renders whatever ladder the dojo has. Nineteen is a Cuong Nhu number; ten is an ATA number; > the next dojo will have neither. That extends to language. "Kyu" and "dan" are Japanese-lineage terms; a BJJ academy has neither, and a Taekwondo school uses "gup". Shared components say **rank**; the dojo's own labels come from its ladder rows. Same for honorifics — see [content format](content-format.md). ## The swatch, and the ring that makes it legible A rank renders as a small circular swatch: [`BeltDot`](../../../src/components/ui/BeltDot.tsx) on mobile, `BeltSwatch` in Studio. **Every swatch carries a 1px `border.control` ring — every rank, both themes, no exceptions.** This is not a nicety. Measured against `surface.card`, at the 3:1 that SC 1.4.11 requires of a meaningful non-text mark: | | Light | Dark | |---|---|---| | Fails | white 1.00, yellow 1.65, orange 2.70 | black 1.02, purple 1.80, camo 2.06, brown 2.38, blue 2.95 | | Count | 3 of 10 | 5 of 10 | Eight failures, at both ends of every ladder — the white belt who just started and the black belt who has been there twenty years. Conditional rings do not solve this: a rule like "ring it if the fill is white" leaves the black belt invisible in dark mode, which is exactly what shipped. A ring on *everything* solves all eight at once, because `border.control` itself clears the threshold against the card in both themes (3.53 light, 3.16 dark) no matter what it surrounds. The ring width is `borderWidth.hairline`. Half-pixel borders vanish at dpr 1 and round inconsistently at dpr 3. Rendering all nineteen ranks at 10 / 16 / 28pt in both themes confirms the arithmetic and shows what it costs to skip: without the ring, in dark, the black belt is three faint smudges and the entire dan progression reads as **red stripes floating on nothing**, because the black belt behind them has merged into the card. With it, every rank has an edge and the dan belts are belts again. ## Size carries the ladder, and 10pt does not The same render surfaced something the arithmetic could not. `BeltDot`'s default size is **10pt**, and at 10pt a nineteen-rank ladder collapses: shodan, nidan, sandan, yondan and rokudan are all a small dark circle with a hint of red, and nothing distinguishes them. That is not a bug in the component — 10pt is fine for what it usually does, which is signal *that* a person has a rank next to their name. But it means: - **10pt says "ranked", not "which rank".** Legal only where the exact rank is also present as text, or is not the point. - **A rank is never chosen from chips.** [Pickers](../patterns/sheet-picker.md) prefer chips at six options or fewer, but a chip is 32pt tall and cannot carry a 16pt swatch with its ring and stay a chip. A short ladder still uses the sheet: rows are the only control where the swatch is legible and the rank's name sits beside it. - **Where the specific rank matters — a roster tile, a profile, a promotion — use 16pt or larger**, and 28pt where the rank is the subject. - **The rank's name is always available as text or in the containing label.** The swatch is never the sole carrier at any size; see the screen-reader contract below. ## Rank colour is data, not theme Belt colours are the dojo's, not Konjo's. They do not live in the palette, they do not invert by theme, and they are not subject to the red budget — a red belt is a fact about a person, not an accent. But that means they get no protection from the theme either, so: - **The ring is what guarantees legibility**, not the colour. See above. - **Never derive a UI colour from a rank.** No belt-tinted row backgrounds, no belt-coloured avatar rings, no gradient seeded from a rank. A representative colour extracted from a ladder row has no contrast contract with anything, and using it as a surface or a text colour is how a screen becomes unreadable for exactly one student. - **Rank appears once per element.** A row with a belt dot does not also carry a belt-coloured border and the rank spelled out in a coloured chip. Dot plus text, or dot alone with the rank in the `accessibilityLabel`. ## Screen-reader contract A swatch is a coloured circle: it says nothing on its own, and colour is the only channel it uses — which is a failure of SC 1.4.1 if it is the sole carrier of the fact. - **The rank's label always reaches the screen reader**, either as adjacent text or inside the containing row's `accessibilityLabel`. On the [tile grid](../patterns/tile-grid.md), where the dot is the only visible rank marker, the tile's label spells it: *"Ana Torres, white belt, not checked in, waiver missing."* - **The swatch itself is decorative** once its meaning is in the label — `accessibilityElementsHidden`, so the rank is not announced twice. ## Two ranks must never render the same A ladder where two rows produce an identical swatch has lost information. In the shipped Cuong Nhu ladder, `nidan` and `rokudan` are both *two red stripes on black* and are indistinguishable — a sixth-degree black belt renders exactly like a second-degree. **Rendering distinctness is a property of the ladder, and the authoring surface is where it is enforced.** Studio's ladder editor should refuse to save two rows with the same visual, the same way it would refuse two rows with the same name. Downstream, a swatch is never the only thing distinguishing two ranks in a list — the label is always there too. ## The visual model `style_ranks.belt_visual` is jsonb. Today it carries `{color, stripeColor?, stripeCount?}`, which expresses solid and striped belts and nothing else. That is narrower than the Cuong Nhu ladder actually needs. Three of its nineteen ranks — godan's split belt, hachidan's end cap, kudan's alternating bands — cannot be expressed in that shape at all, so a dojo authoring the same ladder through Studio silently gets three ranks that render as plain stripes. **Mobile and Studio can therefore show different belts for the same person**, which is the sharpest form of the drift this design system exists to prevent. The target is one model that both surfaces read, expressive enough for the ladders that exist: | Kind | Shape | |---|---| | `solid` | one fill | | `stripes` | base plus 1–4 evenly spaced bands | | `split` | two halves | | `end-cap` | base plus a band at one end | | `alternating` | N equal bands of two colours | One schema, one renderer contract, one set of colours per ladder row — and the built-in ladders seeded through the same path as a dojo's custom one, rather than living as constants in two apps. ## Never - A hardcoded ladder in a component. - A conditional ring. - A UI colour derived from a rank. - Rank conveyed by colour alone. - Two ladder rows that render identically. - A rank name assumed to be Japanese. --- Source: https://getkonjo.com/design/foundations/content-format (repo: docs/design/foundations/content-format.md) # Content format Dates, numbers, names, counts, and what happens when something does not fit. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. Formatting decisions get made per-screen by whoever is building it, and that is how one app ends up showing "2 days ago", "Aug 20", "20/08/2026" and "2026-08-20T14:00:00Z" on four adjacent surfaces. These are settled here so they stop being decisions. ## Dates and times **Choose by how the person will use the fact.** | Situation | Format | Example | |---|---|---| | Something upcoming, within a week | Weekday and time | `Thu 6:30pm` | | Something upcoming, further out | Date and time | `Sep 4, 6:30pm` | | Something upcoming with **no time of day** | Date alone, no filler | `Sep 4` | | Something that just happened, under an hour | Relative | `12 min ago` | | Something that happened today or yesterday | Named day and time | `Yesterday 7:15pm` | | Something older | Absolute date | `Aug 20` | | Older than the current year | Date with year | `Aug 20, 2025` | **A date with no time is written as a date, never padded to look like one.** A birthday, a test date, a due date, a trial expiry — none of these has a clock, and every "upcoming" row above carried one until round 8 needed four date-only fields on one screen. Never write `Sep 4, 12:00am`, `Sep 4 (all day)`, or `Sep 4 —`. - **Relative time stops at "yesterday".** "23 days ago" makes a person do arithmetic to recover a date they could have just been shown. - **Never both.** "Aug 20 (2 days ago)" is two answers to one question. - **Times are local to the reader** and lowercase — `6:30pm`, not `6:30 PM` or `18:30`. - **A range collapses what repeats**: `Thu 6:30–7:30pm`, `Sep 4–6`. - **Absolute, always, for anything with consequence.** A waiver signature, a promotion date, a payment — those show the **full date including the year**, `Aug 22, 2026`, even in the current year. The table above drops the year for ordinary dates because the reader supplies it from context; a record is read later, out of context, sometimes years later, and a date without a year stops being a record. ## Numbers - **Counts are plain integers** up to 999, then abbreviated: `1.2k`, `14k`. - **A badge count caps at 99+.** The exact number stopped mattering long before that, and the dot has a fixed size. - **Money always shows currency and two decimals**: `$45.00`, never `$45`. A price that changes shape between rows is a price people distrust. - **Percentages are whole numbers** unless the fraction is the point. `73%`, not `73.2%`. - **A percent delta needs a base of at least 10** (ten dollars, for money). Off a smaller base one unit of movement swings the figure by double digits — “+3100% vs 1” reads as a bug, not a trend. Show the absolute change instead: “+31 vs 1”. In code: `formatYoyDelta` in Studio's `homeDashboard.ts`. - **Zero is written, not hidden.** "0 students" beats an empty cell, which reads as a bug. - **Never a decimal on something uncountable.** "4.5 classes" is not a thing. ## Durations Time *at* something, as opposed to time *since* something. - **Months up to two years, then years and months**: `14 months`, `2 years 3 months`, `5 years`. Never `1.2 years`, never `427 days`. - **Under a month, use weeks**: `3 weeks`. Under a week, the app almost never needs a duration — use a date. If what you actually want is *how long has this been sitting*, that is [dwell](#dwell--how-long-this-has-been-sitting), not a duration, and it has its own rules. - **Round down, never up.** Someone at a rank for 13 months and 29 days has been there 13 months; rounding up overstates a fact people care about. - **Unknown is a legitimate value** and is rendered as such, not as zero. A student who trained elsewhere before joining has an unknown time at rank, and `Unknown` is the honest answer — this is the case [content missing](#empty-and-unknown) exists for. ## Dwell — how long this has been sitting A **third** kind of time, and the one every queue in Studio needs: leads, dunning, sub requests, the at-risk list, the waiver chase. Not time *at* something (a duration — an achievement) and not time *since* an event (relative time — which stops at "yesterday"). Dwell is a **debt**: how long a thing has been waiting for a person to act on it. - **Always days, plainly.** `Today` · `1 day` · `13 days` · `47 days` · `90+ days`. - **Never weeks or months.** Applying the duration rules above to a dwell renders a 13-day stall as "1 week" — because durations round down — which understates exactly the fact the screen exists to surface. A duration rounds down because overstating time-at-rank flatters someone; a dwell must never round down, because understating a stall hides work. - **Never relative phrasing.** "13 days ago" makes a person do arithmetic (see above); "13 days" is the answer they wanted. - **One format at every age**, so a column of them sorts by eye. A format that changes shape at day 7 makes the column unscannable, which is the one thing it is for. - **Cap the display, not the value.** Past 90 days write `90+ days` and keep sorting on the real number. - **Dwell is never a colour on its own.** Whether a number of days is a warning is an SLA question, which is a [product fact](../product-facts.md), not a palette one. ## Pluralisation Write both forms; never `student(s)`, never `1 students`. For zero, prefer words over the digit where the sentence allows: "No classes this week" beats "0 classes this week" in prose, while a stat tile keeps the numeral. ## Names and people - **A person is their full name** the first time and in any list. Not "A. Torres", not a username. - **Initials are two characters**, first and last. One name gives one initial. - **Never truncate a name mid-word.** If it does not fit, the layout is wrong — a name is the one string a person will always notice being mangled. - **Honorifics come from the art**: sensei, sifu, and whatever the dojo's style uses. Konjo is multi-style; do not hardcode one tradition's title into a shared component. See [rank](rank.md). ## Truncation **Truncation is not a layout strategy.** It is what happens when a layout already failed. - **Never truncate**: a person's name, a belt rank, an error message, a price, a date, a primary action's label. - **May truncate at two lines**: a post body, a description, a note — with the full text one tap away. - **May truncate at one line**: a secondary metadata line where the leading words carry the meaning. - **If it does not fit, it reflows.** A title wraps to three lines and then steps down a type size; it does not get an ellipsis. - **Ellipsis at the end, never the middle**, except for a filename where the extension is the useful part. Combined with text scaling: whatever you test at 100%, retest at 200%, where every one of these gets harder. See [accessibility](accessibility.md). ## Empty and unknown - **Missing optional data shows nothing** — no "—", no "N/A", no empty label. The row is shorter. - **Missing required data is an error**, not a dash. If a class has no time, that is a bug the screen should surface, not smooth over. - **"Unknown" is only correct when unknown-ness is the fact** — an unrecognised belt from a visiting student, for instance. ## Lists of things - **Two items join with "and"**: `Kata and Footwork`. Three or more use commas with a final "and". - **Long lists cut at three and count the rest**: `Ana, Marcus, Priya and 4 others`. - **Order by what the reader is looking for** — soonest first for events, most recent first for a feed, alphabetical only when there is no better answer. --- Source: https://getkonjo.com/design/foundations/accessibility (repo: docs/design/foundations/accessibility.md) # Accessibility Everything past contrast: screen readers, text scaling, reduced motion, and focus. > Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine > apply here unless this chapter contradicts them. Contrast is settled in [L1](../konjo-design-language.md) and computed for you in the [token tables](../../../.claude/skills/design/references/tokens.md). This chapter is the rest of it — the parts that are invisible when they work and humiliating when they don't. The person this chapter is for is the same white belt in week two. They are not a separate audience. Half of what follows is also just *quality*: text that reflows, a control that announces what it does, an animation that stops when someone asked for animations to stop. ## Where Konjo actually stands Measured, not assumed, so the gap is known rather than guessed at: | | Coverage | |---|---| | `accessibilityLabel` | 843 uses across 250 files, against 980 pressables — broadly good | | `accessibilityRole` | 805 uses across 258 files — broadly good | | `accessibilityState` | 131 uses across 89 files — partial | | `accessibilityHint` | **8 uses across 5 files** — effectively absent | | Reduced motion | **5 of the 19 files that animate** honour it | | Text scaling | **no file** sets `maxFontSizeMultiplier`; nothing has been tested at 200% | Labelling is in decent shape. The three rows below it are the work. ## Screen readers **Every pressable needs a label, and the label is what it *does*, not what it says.** A chevron row labelled "chevron" is useless; the same row labelled "Ana Torres, brown belt" is the whole screen for someone who cannot see it. (Not "…, open profile" — the `button` role already said that, and repeating it is the noise the hint rule below warns about.) - **`accessibilityRole`** on everything interactive: `button`, `link`, `header`, `image`, `switch`, `checkbox`, `radio`, `tab`, `search`. The role is what tells the screen reader how to *announce* the element and what gestures apply to it. - **`accessibilityState`** for anything with a state: `{ selected }` on a chip or tab, `{ checked }` on a toggle, `{ disabled }`, `{ expanded }` on an accordion, and **`{ busy: true }` on a control in flight** — a button relabelled "Submitting…" with a spinner has changed state in two visual channels and none an assistive technology reads. A selected chip that does not say it is selected is indistinguishable from an unselected one. - **`accessibilityHint`** only when the outcome is not obvious from the label. "Double tap to open" is noise — the role already said that. "Removes this student from tonight's roster" is worth the extra sentence. Under-using hints is a much smaller sin than over-using them, which is why 8 uses is closer to right than 800 would be. - **Group a row into one stop.** A list row with an avatar, a name, a belt dot, a status glyph and a chevron is *one* thing, not five. Put `accessible={true}` on the row and one `accessibilityLabel` that reads the whole thing in the order a person would say it. The [tile grid](../patterns/tile-grid.md) depends on this: one glyph shows, but the label enumerates every status the tile carries, so nothing is visible-only. - **Hide decoration.** A glyph that repeats the adjacent text, a divider, a spacer image: `accessibilityElementsHidden`, `importantForAccessibility="no-hide-descendants"`, or simply no label. Announcing it twice is worse than not announcing it. - **Announce what changed silently.** A toast, a validation failure, an "8 students checked in" counter that updates without navigation — a sighted user sees it and a screen-reader user gets nothing. Use `AccessibilityInfo.announceForAccessibility()` for the one-shot, or `accessibilityLiveRegion="polite"` on the region that mutates. Currently 3 uses in the whole app; nearly every async success and failure is silent. - **Never type capitals to get uppercase.** `textTransform: 'uppercase'` renders the same and reads correctly; a literal `"ADD"` is announced "A. D. D.". `design-check` enforces this as `literal-caps`. ## Text scaling React Native scales text by default — `allowFontScaling` is `true` unless you say otherwise — and iOS accessibility sizes reach roughly **310%**, which turns 15pt body text into ~46pt. **No file in Konjo sets `maxFontSizeMultiplier`, and no screen has been checked at large sizes.** So this section is a specification, not a description. The rules: - **Never turn scaling off.** `allowFontScaling={false}` is not a fix; it is opting a person out of the accommodation they asked the OS for. - **Cap it where the layout genuinely cannot give**, and only there. `maxFontSizeMultiplier` belongs on chrome with a hard geometric constraint — a tab bar label, a badge count inside a fixed dot, a chip that must stay on one line. Body copy, titles, form labels, empty states and error messages take whatever the person set. - **Suggested caps**, so this is not decided per-screen: tab labels `1.3`, badge counts `1.4`, chips and pills `1.6`. Everything else uncapped. - **No fixed-height container around text, ever.** Height comes from the content. This is the single most common way scaling breaks a screen: a `height: 44` row clips its own label at 150%. A **minimum** height is not a fixed height and is how you hit a target size or a density figure without breaking this law — `minHeight: 44` on a row, never `height: 44`. Studio's dense `46` table row is a minimum for exactly this reason; see [Studio metrics](../studio/metrics.md#density). - **11 of 25 type tokens carry a `lineHeight`; 14 do not** — the generated tables name them. A fixed `lineHeight` scales with the font in RN, so it is safe — but an entry *without* one reflows unpredictably. Prefer a token that has one, and see the [type chapter](type.md) for which. - **Test at three sizes, not one.** 100%, 135%, 200%. Two of those will find something. **One exemption to the 11pt floor, stated so nobody "fixes" it:** `tabLabelActive` and `tabLabelInactive` are 10pt. That matches the platform — iOS tab bar item titles are 10pt — and the tab bar is chrome the OS itself sizes. It is the only place under 11pt, and it needs the `1.3` cap above precisely because it starts small. ## Reduced motion **14 of the 19 files that animate ignore the setting.** The hook is `useReducedMotion()` from `react-native-reanimated`, already used correctly in `ListFadeIn`, `ReflectPill`, `WizardProgressBar` and `WizardScaffold` — copy one of those. - **Reduced motion means replace, not delete.** A slide becomes a cross-fade; a spring becomes an instant state change; a progress bar jumps to its value instead of counting up. The information still arrives. Removing the transition entirely often makes a screen *harder* to follow, not easier. - **Anything that moves more than a few points, parallaxes, scales, or auto-plays is in scope.** A colour change is not. - The one deliberate celebration — the [promotion certificate](../patterns/celebration.md) — is also the one animation someone is most likely to want, so it degrades to a static presentation of the same certificate rather than to nothing. - Vestibular triggers are a real medical accommodation, not a preference. See [motion](motion.md) for what is allowed to move at all. ## Focus RN-web is the surface CI actually walks, and it is the surface a keyboard reaches. - **Focus is visible, always: 2px `border.focus`.** One of the three sanctioned uses of `borderWidth.emphasis` 2 — see [borders](borders.md). Without it the app fails SC 2.4.13 outright, and forms become unusable by keyboard. - **On a hero surface the ring flips to `border.focusOnHero`.** `border.focus` is near-black on light and near-white on dark, which is correct on a page or a card and catastrophic on `surface.hero`: **`border.focus on surface.hero` is 1.14:1** light and **1.00:1** dark — in dark it is the same hex as the surface. That covers every ink-filled control: Studio's navigation rail, a selected chip, a hero CTA, and any ring that lands *inside* an ink block rather than outside it. `border.focusOnHero on surface.hero` is 16.86:1 light and 14.72:1 dark. **A ring with `outline-offset` ≥ 0 on a small ink control lands on the page behind it, and keeps `border.focus`.** The test is what the ring is drawn *on*, not what it surrounds. This pair is now in the generator's law table, so a palette that breaks it fails the build rather than being published with a ✗ nobody reads. - Never remove the outline without replacing it with something at least as visible. - **Focus order follows reading order.** If a visual reorder puts the primary action above the fields, the DOM order has to say so too. - Opening a sheet moves focus into it; closing it returns focus to whatever opened it. A keyboard user who tabs into content behind a scrim is lost. ## Targets - **44×44pt minimum** (SC 2.5.5), or `hitSlop` making up the difference. `touchTarget` is the token; a 24pt glyph in a 44pt target is the normal shape. - Spacing between adjacent targets matters as much as their size — two 44pt buttons touching are one 88pt mistake. - Studio is pointer input and drops to 32pt; see the [dialect](../studio/dialect.md). ## Before you claim a screen is done - Every pressable has a role and a label that says what it does. - Anything with state announces its state. - Rows are one stop, not five, and decoration is hidden. - Async success and failure are announced, not just drawn. - Nothing is in a fixed-height box with text in it. - It survives 200% text. - Every animation checks `useReducedMotion()`. - Focus is visible and lands in the right order. - Contrast computed, both themes. ## The same contract on the web Everything above is written as React Native props, because that is where most Konjo screens live. **Konjo Studio is a web app**, and a completeness test on a Studio table found the system contains exactly one ARIA attribute — which means the whole contract was being re-derived by whoever built each screen. That is how a safety rule quietly stops being followed. The rules are identical. Only the API changes: | Mobile | Web | |---|---| | `accessibilityRole="button"` | a real `