# Implementation notes

How to build with this system in a real codebase — the things `DESIGN.md` implies but
does not spell out. `DESIGN.md` is the rules; this is the wiring.

---

## 1. Load order and the token layers

```html
<link rel="stylesheet" href="colors_and_type.css">   <!-- @font-face · primitives · roles · dark theme -->
<link rel="stylesheet" href="tokens.css">            <!-- spacing · layout · radius · elevation · motion -->
<link rel="stylesheet" href="ui_kits/app/app.css">   <!-- components -->
<script src="ui_kits/app/app.js" defer></script>
```

Order is load-bearing: `tokens.css` and `app.css` both dereference custom properties
declared in `colors_and_type.css`. Copy `fonts/` alongside `colors_and_type.css` — the
`@font-face` `url()`s resolve against the **stylesheet**, not the page, so they work at any
nesting depth without rewriting paths.

Two faces are bound: **Charis SIL** (display, 28 KB) and **IBM Plex Sans** (interface,
40 KB) — 68 KB total, both OFL 1.1 with licences in `fonts/`. Both use `font-display: swap`,
so content is never invisible. The mono is a system face and costs nothing. Do not add a
third webfont without a measurement that forces it.

If you use CSS cascade layers, this maps cleanly:

```css
@layer tokens, components, pages;
@import url("colors_and_type.css") layer(tokens);
@import url("tokens.css")          layer(tokens);
@import url("ui_kits/app/app.css") layer(components);
```

### Three layers, and which one you are allowed to touch

| Layer | Example | Who consumes it | May a page override it? |
|---|---|---|---|
| **Primitive** | `--forest: #166534` | Only the role layer | **No.** A primitive is the palette. |
| **Role** | `--action: var(--forest)` | Components, pages | Yes — this is the themeable API. |
| **Component** | `.btn`, `.panel` | Markup | Extend, don't fork. |

**The rule:** build with roles. Reach for a primitive only when you are defining a new
role. A page that says `color: var(--forest)` has silently opted out of theming and out of
the accessibility guarantees, because it has claimed a palette value without claiming a job.

Two deliberate exceptions keep their primitives: the brand ribbon (`.topline`) and the two
permanently-dark bands (`.feat`, `.panel-top`). They are fixed devices, not themed surfaces.

---

## 2. Naming convention

- **Surfaces** are nouns for what they mean: `--canvas`, `--evidence`, `--slab`. Not `--bg-1`, `--bg-2`.
- **Text** is `--ink*`, ordered by loudness: `--ink` → `--ink-prose` → `--ink-quiet` → `--ink-margin`.
- **Interaction** is `--action*`, with `--action-on` for the text that sits on an action fill.
- **State** is `--status-*`, never `--red`/`--green`. The colour is an implementation detail of the state.
- **Ordinal data** is a numbered ramp: `--reg-1…8`, `--grade-1…4`. The number *is* the rank.

When you add a role, the test is: **can you name the job without naming a colour?** If the
best name you have is `--light-green-2`, it is not a role yet.

---

## 3. Theming

Dark is opt-in on any element, not just `<html>`:

```html
<html data-theme="dark">          <!-- whole document -->
<div class="panel" data-theme="dark">  <!-- one region -->
```

Because roles are inherited custom properties, scoping works at any depth — which is how
`preview/theme-light-dark.html` renders both themes side by side on one page.

**Do not add `@media (prefers-color-scheme: dark)` yet.** The source declares
`color-scheme: light` and no product decision has been made to follow the OS. When that
decision comes, the one-line change is:

```css
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ /* dark role block */ } }
```

Persist the user's choice the way the system already persists deck position — `localStorage`,
read before first paint to avoid a flash:

```html
<script>try{var t=localStorage.getItem('theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
```

Put that inline in `<head>`, before the stylesheets. It is the one place an inline script
is worth it.

---

## 4. Status vocabulary

The linked pipeline (`lorain-data/CONTEXT.md`) defines a **seven-state claim lifecycle on a
single axis**. The design system carries two colours. This table is the contract between them.

| Product state | Role | Renders as | Notes |
|---|---|---|---|
| `corroborated` | `--status-held` | `.stamp.live` | Survived review. |
| `ready` | `--status-held` | `.stamp.live` | Reachable only from `corroborated`. |
| `active` (edge) | `--status-held` | `.stamp.live` | Projection visibility. |
| `contested` | `--status-pending` | `.stamp.prep` | On the record, not settled. |
| `disproven` | `--status-pending` | `.stamp.prep` | Same signal class — attention, not alarm. |
| `draft` | `--status-quiet` | `.stamp.quiet` | Only `draft` may hold unbound slots or free text. |
| `open` | `--status-quiet` | `.stamp.quiet` | Asserted, not yet corroborated. |
| `withdrawn` | `--status-quiet` | `.stamp.quiet.struck` | Retains identity and provenance for audit. |

`.stamp.quiet` is neutral, not a third hue — the stamp vocabulary is still two
colours. It existed here as a token and a table row before it existed as a class;
the target-page rebuild is what finally needed to render an absent council member
and a stage with no record behind it.

**The rule that keeps this honest:** *"supporting and contradicting are categories of
receipts on the card, never states of it."* The stamp carries the lifecycle state **only**.
Contradicting evidence is a row in the receipts list, not a second badge and never a red one.

### Identity grade

Four steps, strongest first, on the shared ordinal ramp: `attested` → `identified` →
`keyed` → `ambiguous`.

- A relationship's grade is **the weakest of its two endpoints, never the strongest**. A two-endpoint component must render the minimum.
- Never label this axis "confidence" — the product retired that word because it conflated identity with assertion strength.
- `ambiguous` renders, but must never carry a claim.

---

## 5. Receipts

`DESIGN.md` says every figure carries a receipt. The product defines precisely what one is,
and it is stricter than "a link":

> source id · fetch date · record occurrence · raw-payload/content hash where available ·
> counts · caveat line

**A mutable record id alone is not a receipt.** When you build a receipt component, those
are the slots. A `.src` line that says only "County Auditor" is a citation, not a receipt —
it is missing the date, the count and the caveat.

Minimum viable receipt in this system's markup:

```html
<p class="src">
  Lorain County Auditor transfer records · retrieved 2026-07-15 · 4 records ·
  a $0 recorded price is a tax-transfer flag, not a statement that no money changed hands.
</p>
```

Source · date · count · caveat. If you cannot fill all four, say which one is missing.

---

## 6. Accessibility contract

Target: **WCAG 2.2 AA** (the linked product's stated bar).

| Requirement | How this system meets it |
|---|---|
| Body text ≥ 4.5:1 | Every `--ink*` role measured on `--canvas`, `--evidence` **and** `--evidence-inset`, both themes. Lowest is 4.60:1. Verified by `tools/audit-readiness.py`. |
| UI / large text ≥ 3:1 | `--action` clears 4.5:1 on all three surfaces in both themes. |
| No colour-only meaning | Every stamp carries a **word**. The record field carries a full `aria-label` and a `<noscript>` breakdown. The grade ramp is always labelled. |
| Visible focus | 2px `--focus` ring, 3px offset, swapped to `--volt` inside dark regions. Never removed. |
| Keyboard operation | Legend chips are real `<button>`s bound to click **and** focus; `Escape` clears a lock; the nav drawer maintains `aria-expanded` / `aria-controls`. |
| Target size | WCAG 2.2 AA minimum is 24×24 CSS px — cleared everywhere. Buttons compute to **40px** and sit inside 44–52px rows; the touch surface is the row. Measured by `tools/audit-readiness.py`, not asserted. |
| Reduced motion | `prefers-reduced-motion` zeroes every duration; JS reads the same query and prints final count values instantly. |
| Size floor | 10px. Nothing below it. |
| Mobile | Structurally complete at **390px** — see §7. |

**Known limitation, stated rather than hidden:** `--rule` measures 1.29:1 (light) and
1.93:1 (dark) against the canvas. It is a hairline that organises the page, never the sole
indicator of a control boundary or state. If a rule ever becomes the *only* thing
distinguishing two states, promote it to `--ink-quiet`.

---

## 7. Responsive

Four breakpoints ship in the source: 1000 / 960 / 860 / 560px. The product requires a
structurally complete **390px** layout, which is below the smallest declared breakpoint.

At 390px, verify — this is a checklist, not an assumption:

- [ ] No horizontal scroll (`overflow-x:hidden` on body is a backstop, not a fix).
- [ ] The prototype 197-match field wraps to 11px cells and stays inside the gutter.
- [ ] Panel key/value rows do not collide — the serif figure wraps under the key rather than overlapping it.
- [ ] The `.doc` margin note reads *above* its content, not beside it.
- [ ] Filing-tab labels do not clip; the tab sits fully inside the card's left edge.
- [ ] The record table scrolls horizontally inside its own container, not the page.
- [ ] Every touch target still measures ≥ 44px.

---

## 8. Porting to a framework

The CSS layer is framework-agnostic and should stay that way. `ui_kits/app/components/*.jsx`
is a reference binding, not a dependency.

**The one rule for any binding:** a component emits **class names and slots**, never inlined
token values. If a React/Vue/Svelte component contains `#166534`, the port is wrong — a
token change will no longer reach it, and neither will a theme.

```jsx
// right — the stylesheet still owns the value
<span className={`stamp ${state === 'pending' ? 'prep' : 'live'}`}>{children}</span>

// wrong — the token is now dead to this component
<span style={{ color: '#166534' }}>{children}</span>
```

Data colours follow the same rule. `app.js` reads `--reg-1…8` off the computed style rather
than hardcoding the register palette, so the data encoding cannot drift from the tokens and
follows a theme change. Do the same for any new series.

**Server-rendered / Phoenix:** the system needs no build step. Ship the four files as
static assets and use `data-theme` on the layout element. Nothing in `app.js` requires a
module system.

---

## 9. Extending the system

There are exactly three components not observed in the source — `.rt`, `.fld`, `.tabs` —
and they sit under an `EXTENSIONS` banner in `app.css`. When you add a fourth:

1. **Build it from roles.** If you need a new colour, you need a new role first, and a reason.
2. **Put it under the `EXTENSIONS` banner**, not among the observed components.
3. **Stamp it** in `ui_kits/app/components.html` so provenance stays visible.
4. **Record it** in `context/provenance.md` under observed-vs-derived.
5. **Measure it** — contrast for every text pair, both themes, before it ships.

Provenance is a feature of this system, not bookkeeping. The project's entire claim is that
you can check its work; the design system holds itself to the same standard.

---

## 9b. Writing about measurements

Every contrast figure printed in this package is checked against the live CSS by
`tools/audit-readiness.py`. Two rules make that possible:

1. **Put the token on the same line as its ratio.** The checker anchors a figure to
   the tokens named beside it. `--ink-quiet is 5.13:1 on --canvas` verifies; a ratio
   whose subject sits in the previous sentence cannot.
2. **Mark superseded figures with a past-tense word** — *was, were, previously,
   old value, superseded, used to, had been*. A system that records why a value
   moved will legitimately print the number it moved from; the checker skips those
   lines rather than demanding they match the present.

If you change a colour, do not hand-edit the ratios. Change the value, run the
audit, and fix what it reports.

## 10. Pre-ship checklist

**Run `python3 tools/audit-readiness.py` first.** Fourteen automated checks —
file references, live contrast in both themes on all three surfaces,
docs-vs-code figure drift, dead tokens, primitive leaks, the 10px floor,
type-scale sync, component documentation coverage, the system's own hard rules,
the ≤430px breakpoint, computed touch-target size, the focus ring, the
conventional-name artifacts, and whether those artifacts still match the
canonical sources they were generated from. Exit 0 means the package is
publishable.

**If you changed a token, a component or the six rules, regenerate the exports**
— `python3 tools/export-artifacts.py`. `brand.json`, `variables.css`,
`theme.json`, `kit.html` and `kit.dark.html` are derived files; the audit fails
if any of them has drifted from its source. Never edit one by hand.

Then run this by eye against any new page:

- [ ] Zero raw hex in the page's own CSS. Zero primitives outside the two documented exceptions.
- [ ] Volt appears in at most its four named roles, and no more than once each.
- [ ] Every headline figure is `var(--serif)` + `tabular-nums`.
- [ ] Every material figure has a receipt: source · date · count · caveat.
- [ ] Every stamp carries a word, not just a colour.
- [ ] The "not affiliated with the City of Avon Lake" disclaimer is in the masthead **and** the footer.
- [ ] At most one `.feat` band before the footer.
- [ ] Focus ring visible on every interactive element, in both themes.
- [ ] `prefers-reduced-motion` lands on the same content.
- [ ] 390px: no horizontal scroll, nothing clipped — verified live in `preview/narrow-390.html`.
- [ ] Both themes rendered and eyeballed — `data-theme="dark"` is not free.
- [ ] The page uses the system's words: a state is `corroborated` / `contested` / `open`, never a "confidence".
- [ ] `python3 tools/export-artifacts.py` run, if anything canonical moved.
- [ ] `python3 tools/audit-readiness.py` exits 0.

---

## Related

- `DESIGN.md` — the rules
- `README.md` — package guide and preview manifest
- `SKILL.md` — agent-facing build instructions
- `preview/token-roles.html` — the semantic layer, live
- `preview/theme-light-dark.html` — both themes, measured
- `context/provenance.md` — what was read, what was excluded, and why
