# DESIGN.md

Judgment for agents that build AnyKnown pages. This file covers only what the code and tokens can't tell you: how to put a page together and what must never appear on one. It copies no values. The published `https://ui.anyknown.com/design.md` ends with an appendix (the token table and the `brand.css` vocabulary) that the build generates from source.

There are two kinds of reader. Agents inside our apps already use `@anyknown/ui` with StyleX; component rules live in `src/components/README.md`, and this file covers the page level. Outside agents (v0, Claude, Codex) produce one-off HTML pages (reports, proposals, benchmarks, event pages) with no bundler and no React. They can only `<link>` `https://ui.anyknown.com/brand.css` and lay out the page with its `ak-*` classes. The vocabulary is small on purpose: a class that isn't in the table is not allowed.

## 1. What this is

Pencil sketch. One sheet of paper, no lines on it, nothing behind the words, and color only for status. Hierarchy comes from space, weight, size and one gray. Shape is only for things you press and things that float.

- No lines. No 1px frames, no dividers, no ring around a resting block, no rule between dates or list items. The keyboard focus ring is the only line, plus the one exception for unchecked checkboxes and radios below.
- Nothing behind the words. No bubbles, no frame around a list of tool calls, no gray fill behind code or table headers, no gray row for background work, no card around a question. Only things you press (chips, buttons, the selected row) carry a wash: ink at 6% (`--ak-wash`), 11% on hover (`--ak-wash-hover`). A wash is a tint, not another sheet.
- Hierarchy uses four things only: space, weight, size and one gray (`--ak-text-muted`, the `ak-muted` class). There is no fainter gray for text. Don't shrink text and gray it too to say "secondary"; pick one.
- A surface is only for something that floats above the page, and never more than one level. Text fields and the composer, menus, sheets, dialogs, toasts and tooltips use `--ak-surface-raised` with `--ak-shadow-float` (dialogs and sheets use `--ak-shadow-modal`). The page and the thread are always level zero: no desk under a sheet, no card on the paper, no card inside a card.
- Color reports status and nothing else, and only on text or a 6px dot. Blue (`--ak-signal`) means working, red (`--ak-danger`) failed, amber (`--ak-warning`) waiting for you, green (`--ak-success`) done. Blue also draws the focus ring and progress. No tinted status chips, no tinted backgrounds, no colored circles behind icons. Color used as decoration is always wrong.
- Who is speaking is shown by position and weight. The user's words sit on the right at weight 500 with no bubble; the reply starts at the left edge at weight 400 and fills the column.
- One left edge, at most one step of indent (16px, `space.md`). Reply text, tool calls, failures, questions, reports and the working row all start at the same edge; only what is folded inside one of them steps in.
- Three corners, each with one job. `corner.shape` (a pill) is for shapes that are always one line tall: buttons, chips, single-line text fields, icon buttons, a row in a menu. `corner.box` (24px) is for boxes that grow with their content: the composer, textareas, the menu itself, dialogs, the top edge of a sheet, toasts, tooltips, images, maps; a box keeps this corner when its text wraps. `corner.check` (4px) is only for the checkbox square, because a pill-shaped square is a circle and looks like a radio. Radios stay round; switch tracks and thumbs and slider thumbs are pills.
- Sections are separated by space only, and density is tight: 24px between speakers (`space.lg`), 12px within one turn (`space.sm`), 4px inline (`space.xxs`). A row is at least 28px tall; a row you tap in a sheet or under `breakpoint.phone` is at least 44px.
- There is no accent color; all color is kept for status. The primary action is a solid ink pill, one per page. Links are ink and stand apart from body text by their underline (`a` inside `ak-prose` already does this). A destructive action is red text on a wash, never a solid red fill.
- `--ak-border-control` is the one edge left, and only for unchecked checkboxes and radios (1.5px): WCAG 1.4.11 asks for 3:1 against the background, and a wash reaches only about 1.3:1. Text fields get their edge from the floating shadow, not a frame. A switch needs no edge; its gray thumb already passes 3:1.
- Grays have chroma 0, neither cool nor warm. Pages use only the semantic `--ak-*` variables; the scales behind them are not public.
- Headings and body text use Geist (headings 600 to 700, body 400), falling back to Noto Sans TC for Chinese. Data and code use Geist Mono, from the same family.

The tone is still a ledger: quiet, comparable, no ornament. Readers come to make a decision, and the page's job is to line up the evidence, not to persuade.

## 2. Frame the reader's task, then pick a structure

Before laying out any one-off page, answer one question: what does the reader need to decide when they open it? If you can't answer, don't start.

Once you can, pick one of three skeletons. Don't invent your own.

- Single-column long read. Reports and post-mortems. One `ak-display` inside `ak-page`, then `ak-prose`, with evidence tables in between. The reader goes top to bottom.
- Conclusion first, evidence grid after. Proposals, benchmarks, pricing comparisons. The first screen is the conclusion (a paragraph of `ak-prose` and a row of `ak-stat`); below it come `ak-table` and `ak-grid-2` / `ak-grid-3`, split into `ak-section` blocks. The reader sees the answer first and then decides whether to check the evidence.
- Controls first. Interactive planning pages and sign-up pages. `ak-btn` and the form (one `ak-field` per field, label on top) are on the first screen, with the explanation below. The reader came to act, not to read. A sign-up page opens with the form, not a row of stats; one sentence next to the button is enough to say how many seats are left.

All three share the same type scale and spacing. Structure follows the reader's goal, not the amount of data: when there is a lot of data, add sections, don't switch skeletons.

## 3. Laying out evidence

- Put numbers that are compared on the same ruler: same column, same unit, same baseline. When two numbers sit in different columns or units, the reader has to convert them and the comparison fails.
- Evidence tables run full width, never inset in a card. Put `ak-table` straight into `ak-section`, as wide as the body text. There are no cards: one object (a file, an attachment, the result of one tool call) is one block of text separated by space, and a table is part of the text like everything else.
- One main chart per page; everything else is a table. A chart gives an impression and a table allows comparison. With two or more charts the reader doesn't know where to look.
- Use `ak-stat` for big numbers (one `ak-stat-value` and one `ak-stat-label` inside), at most four in a row, and only for conclusion-level numbers. A conclusion-level number is one the reader can decide on after seeing it, usually a difference computed from the table: a ratio, a delta, what's left, how far over budget. Don't enlarge a cell the table already has (a total, a cost, one option's score); lint checks every stat value against the table cells. If a page has no conclusion-level number, it has no stats. Three stats are not required.
- Numbers in the same unit get exactly one table. If pricing, cost and competitors are all USD per minute, that is one table; split into three, the reader has to compare across tables. Another dimension means another column, not another table.
- Don't make a table out of two columns. In a full-width table with only two columns the numbers get pushed to the far right and the eye loses the row. Use a list in `ak-prose` instead, or fold it into another table as a column.
- For the one sentence the reader must notice, use `ak-callout` with `ak-callout-warning` / `ak-callout-danger` (blue only means working, so there is no info callout). At most two per page; when callouts are everywhere, nothing stands out. A callout doesn't repeat the body text and doesn't take up an `ak-section` of its own; it sits right next to the table or button it is about.
- Numbers are tabular (`ak-table` and `ak-stat` already turn this on), decimals line up within a column, and units go in the header, not in every cell.
- `data-num` goes on a whole column, header included, or on none of it. A column that mixes words and numbers (two options side by side, one attribute per row) stays left-aligned; half right-aligned, the column has two edges.

## 4. Hierarchy and type

- One display heading (`ak-display`) per page. A second display heading means a second page.
- `ak-h1` is for section titles and `ak-h2` for subsections within a section. Text at `type.t5` or larger may only open a block, never sit mid-paragraph or inside a table.
- Body lines are 60 to 75 characters long. `ak-prose` already sets a max width; don't stretch it. On wide screens, leave the extra space empty instead of filling it.
- Spacing uses only the `space.*` steps (table in the appendix). Made-up values such as 13px or 18px are never allowed; if neither neighboring token fits, pick the smaller one.
- Color uses only the `--ak-*` variables. Made-up hex is the most often broken rule in this file, so lint counts it. Never hand-tune a color: to change one, edit the scale in `scripts/palette.mjs` and rerun it, rather than swapping in a similar hex on the page.
- Use `ak-muted` for secondary notes; don't shrink the type to signal that something is secondary. Size expresses hierarchy and color expresses importance; don't stack the two.
- Monospace is only for data, code and identifiers (`ak-mono`). Monospace as decoration pretends the page is a terminal.
- Light is the default and dark follows the OS. `ak-theme-light` / `ak-theme-dark` are only for pages that must lock a theme; lock the whole page, never mix the two on one page.

## 5. Copy

- Use sentence case, not Title Case. A heading is a sentence, not a sign.
- Buttons start with a verb: 「送出報名」 (Submit registration), not 「報名表單」 (Registration form). The reader should know what happens before they press it.
- Errors say how to fix the problem, not sorry. 「金額要大於 0」 (Amount must be greater than 0) helps more than 「抱歉,發生錯誤」 (Sorry, something went wrong).
- Numbers are tabular, thousands are separated with commas, and there is one space between a number and its unit.
- Don't write 「強大」「無縫」「一站式」 (powerful, seamless, all-in-one). A ledger doesn't describe itself.

## 6. Motion

- Only ease-out and linear. No bounce, no overshoot, no spring.
- If something can stay still, keep it still. One-off pages usually need no motion at all; when they do, animate only opacity and transform, with `--ak-motion-*` durations.
- Respect `prefers-reduced-motion`. `brand.css` already handles `ak-btn`; any animation you add is yours to handle.

## 7. Anti-patterns

Each one by name. When you see one, fix it without asking.

- The generic SaaS row of three KPI cards: three rounded cards, each with a number and an icon. Use a row of `ak-stat`, no cards.
- Gradient cards, frosted glass, `backdrop-filter`. Paper has none of these.
- Icons on colored circles. Icons are line, single-color, and the same color as the text.
- A hero image for every section. At most one main image per page (section 3).
- Two primary buttons on one page. One `ak-btn` per page; everything else is `ak-btn-secondary`.
- A table stuffed into a card with a shadow. Tables run full width with no shadow.
- Made-up hex, `rgb()`, `hsl()`. Use only `--ak-*`.
- Made-up classes. Nothing outside the vocabulary is allowed. For a new layout need, change `brand.css` first, then this file.
- Inline styles. One-off pages are no exception; inline style is how made-up values get in.
- Fonts that aren't tokens: Inter, Roboto or `system-ui` written straight into `font-family`. Use `--ak-font-*`.
- Overshoot béziers and `animation: bounce`. See section 6.
- Any card. The page is one sheet; to group things, use space, and to rank them, use weight and size. A card, a frame or a shadow on the page means the hierarchy wasn't thought through.
- Any line: borders, dividers, rules between rows. Space separates; the only edges are the focus ring and the unchecked checkbox or radio (section 1).
- All-caps eyebrows (`text-transform: uppercase` plus letter-spacing). That is someone else's language.
- `useEffect` (internal agents). Use a ref callback with cleanup; see `src/components/button` for an example.

## 8. Pre-delivery check

Go through this yourself before handing the page over:

- Check both light and dark. Dark isn't inverted light; it is a separate set of tokens. If you haven't looked at dark, you haven't finished.
- It must read at 375px wide. `ak-grid-*` collapses to one column on its own; tables must scroll sideways instead of widening the page.
- Put a screenshot next to the components on `https://ui.anyknown.com`; no one should be able to tell they come from two places. Buttons especially: `ak-btn` and the playground's Button should look like the same button.
- Run `node scripts/design-lint.mjs <html>`. Made-up hex, out-of-vocabulary classes and inline styles must all be 0.

---

## Appendix (generated; do not edit)

Generated at build time by `scripts/design-md.mjs` from `src/tokens.stylex.ts` and `brand.css`.

### Shortest usage

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Monthly usage report</title>
<link rel="stylesheet" href="https://ui.anyknown.com/brand.css">
</head>
<body>
<main class="ak-page">
  <h1 class="ak-display">August usage report</h1>
  <div class="ak-prose">
    <p>API calls grew 18% over last month; cost held flat.</p>
  </div>
  <table class="ak-table">
    <thead><tr><th>Product</th><th data-num>Calls</th><th data-num>Cost (USD)</th></tr></thead>
    <tbody>
      <tr><td>product</td><td data-num>1,204,311</td><td data-num>412.50</td></tr>
      <tr><td>call</td><td data-num>88,102</td><td data-num>3,120.00</td></tr>
    </tbody>
  </table>
</main>
</body>
</html>
```

### brand.css vocabulary (22 classes; anything not listed is not allowed)

| Class | Purpose |
| --- | --- |
| `ak-theme-light` | Locks light by hand; on html it locks the whole page, on a subtree only that part. |
| `ak-theme-dark` | Locks dark by hand. |
| `ak-page` | Main sheet: the one page, max width, no desk, no corners. One per page. |
| `ak-section` | Section: space above, no rule. Conclusion-first pages use it to split the evidence sections. |
| `ak-grid-2` | Two-column grid; folds to one column at 375 wide and below. |
| `ak-grid-3` | Three-column grid; folds to one column at 375 wide and below. A row of stats holds at most this many plus one. |
| `ak-display` | Display-level title. One per page. |
| `ak-h1` | Section title. |
| `ak-h2` | Subsection title inside a section. |
| `ak-prose` | Body text: hierarchy and line length for p / ul / ol / a / code / pre / blockquote / h3. |
| `ak-muted` | Secondary note: color one step down, same size. |
| `ak-mono` | Monospace: data, code, identifiers. Not for decoration. |
| `ak-table` | Evidence table: full width, not in a card, tabular-nums; no lines, the header is small muted text, rows are split by space. Add data-num to right-align number columns. |
| `ak-stat` | Conclusion-level big number: a figure in the display face with a muted label. At most four per row. |
| `ak-stat-value` | The number in ak-stat. |
| `ak-stat-label` | The label in ak-stat. |
| `ak-callout` | Callout: one line of text; ak-callout-warning / -danger set the meaning with text color and a 6px dot. No fill, no side line. |
| `ak-callout-warning` | Needs attention but doesn't block. |
| `ak-callout-danger` | Something will go wrong. |
| `ak-field` | Form field: label above, control below; input / select float on a raised surface, textarea uses the box corner, all share one focus ring. No border. |
| `ak-btn` | Primary action button: solid ink pill, one per page. |
| `ak-btn-secondary` | Secondary button: every other action uses it; a wash pill. |

### Color tokens (`--ak-*`)

Light is the default and dark follows the OS; `ak-theme-light` / `ak-theme-dark` lock one by hand.

| Variable | light | dark |
| --- | --- | --- |
| `--ak-bg` | `#FFFFFF` | `#121212` |
| `--ak-surface-raised` | `#FFFFFF` | `#1F1F1F` |
| `--ak-border-control` | `#808080` | `#747474` |
| `--ak-text` | `#171717` | `#F0F0F0` |
| `--ak-text-muted` | `#5C5C5C` | `#BABABA` |
| `--ak-signal` | `#0169DA` | `#89BAFE` |
| `--ak-danger` | `#CA322E` | `#FE8C80` |
| `--ak-success` | `#246E3A` | `#76CF8A` |
| `--ak-warning` | `#955E02` | `#FAB45F` |
| `--ak-focus-ring` | `#0169DA` | `#89BAFE` |
| `--ak-scrim` | `rgba(0, 0, 0, 0.32)` | `rgba(0, 0, 0, 0.32)` |
| `--ak-wash` | `rgba(23, 23, 23, 0.06)` | `rgba(240, 240, 240, 0.06)` |
| `--ak-wash-hover` | `rgba(23, 23, 23, 0.11)` | `rgba(240, 240, 240, 0.11)` |
| `--ak-wash-strong` | `rgba(23, 23, 23, 0.26)` | `rgba(240, 240, 240, 0.26)` |

| Variable | light | dark |
| --- | --- | --- |
| `--ak-shadow-float` | `0 1px 2px rgba(0, 0, 0, 0.06), 0 10px 24px rgba(0, 0, 0, 0.1)` | `0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 24px rgba(0, 0, 0, 0.4)` |
| `--ak-shadow-modal` | `0 2px 4px rgba(0, 0, 0, 0.06), 0 24px 56px rgba(0, 0, 0, 0.16)` | `0 2px 4px rgba(0, 0, 0, 0.6), 0 24px 56px rgba(0, 0, 0, 0.6)` |

### Fonts, type, space, corners, motion

Every group below is also a CSS variable, named `--ak-<group>-<key>` (`--ak-space-md`, `--ak-type-t3`,
`--ak-corner-box`, `--ak-motion-ease-out`). brand.css already uses them in its classes; when you write your own
CSS, pick values from these tables and do not invent new ones.

### font

| Name | Value |
| --- | --- |
| `font.display` | `'Geist Variable', 'Geist', 'Noto Sans TC Variable', 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', system-ui, sans-serif` |
| `font.body` | `'Geist Variable', 'Geist', 'Noto Sans TC Variable', 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', system-ui, sans-serif` |
| `font.mono` | `'Geist Mono Variable', 'Geist Mono', 'Noto Sans TC Variable', 'Noto Sans TC', ui-monospace, monospace` |

### space

| Name | Value |
| --- | --- |
| `space.xxs` | `0.25rem` |
| `space.xs` | `0.5rem` |
| `space.sm` | `0.75rem` |
| `space.md` | `1rem` |
| `space.lg` | `1.5rem` |
| `space.xl` | `2rem` |
| `space.xxl` | `3rem` |

### motion

| Name | Value |
| --- | --- |
| `motion.fast` | `120ms` |
| `motion.quick` | `160ms` |
| `motion.normal` | `200ms` |
| `motion.slide` | `240ms` |
| `motion.slow` | `400ms` |
| `motion.loopFast` | `0.8s` |
| `motion.blink` | `1s` |
| `motion.loop` | `1.2s` |
| `motion.loopSlow` | `1.6s` |
| `motion.ease` | `cubic-bezier(0.25, 0.1, 0.25, 1)` |
| `motion.easeOut` | `cubic-bezier(0.16, 1, 0.3, 1)` |
| `motion.easeInOut` | `cubic-bezier(0.42, 0, 0.58, 1)` |
| `motion.linear` | `linear` |

### iconSize

| Name | Value |
| --- | --- |
| `iconSize.xs` | `12px` |
| `iconSize.sm` | `14px` |
| `iconSize.md` | `16px` |
| `iconSize.base` | `18px` |
| `iconSize.lg` | `20px` |

### focusRing

| Name | Value |
| --- | --- |
| `focusRing.width` | `2px` |

### type

| Name | Value |
| --- | --- |
| `type.t1` | `0.6875rem` |
| `type.t2` | `0.8125rem` |
| `type.t3` | `0.9375rem` |
| `type.t4` | `1.0625rem` |
| `type.t5` | `1.375rem` |
| `type.t6` | `1.75rem` |
| `type.t7` | `2.25rem` |
| `type.code` | `0.8125rem` |
| `type.phoneInput` | `1rem` |
| `type.body` | `1.6` |
| `type.tight` | `1.35` |
| `type.snug` | `1.45` |
| `type.dense` | `1.2` |

### corner

| Name | Value |
| --- | --- |
| `corner.shape` | `9999px` |
| `corner.box` | `24px` |
| `corner.check` | `4px` |
