# GUIDE — Component contracts

**Status:** law — v1, 2026-08-31
**Applies to:** every interactive component on a Relay surface — marketing web, web SPA, native
app, console, MCP display
**Parent:** [`GUIDE.interface-principles.md`](GUIDE.interface-principles.md)
**Companions:** [`../briefs/SPEC.app-components.md`](../briefs/SPEC.app-components.md)
(cross-surface inventory — sheet, Approve/Deny, bell, segmented control) ·
[`../briefs/BRIEF.ui-component-kit.md`](../briefs/BRIEF.ui-component-kit.md) (the relay-app
work order) · [`GUIDE.motion-register.md`](GUIDE.motion-register.md) (motion law) ·
[`GUIDE.forms.md`](GUIDE.forms.md) (the forms kit)

This is **the single table the app and UI teams read** — per component: the states it owns, the
accessibility behaviour it must carry, and what motion it is permitted. It exists because the
contract surface was split three ways: composition law lived here in `guidelines/`, behaviour
contracts lived in `briefs/SPEC.app-components.md`, and buttons and form fields had no contract
rows anywhere. An engineer asking *"what states does a button have?"* had no page. This is that
page.

The table's shape is adapted from the Execution Space estate's interface guide — the operating
model, not the visual language. Everything in the cells is Relay's own canon.

---

## How to read the table

- **Copy the specimen's markup, never a screenshot of it.** The galleries are the living spec.
  Every specimen on [`../concepts/app-components.html`](../concepts/app-components.html) ends
  with a copyable class list (`.class-list`) naming its exact classes and structure — that block
  is the import statement. Marketing components live in
  `marketing/web/v36-sharpened/site.css`; live form behaviour in
  `marketing/web/stealth/relay-access.js`.
- **The states column is exhaustive.** A state not listed is a state the component does not
  have. Found a screen that needs one more? That's an amendment to this table, in the same PR.
- **The motion column defers to the register.** Timing, easing, reduced-motion behaviour and
  the per-page budget are owned by [`GUIDE.motion-register.md`](GUIDE.motion-register.md);
  entries here say only what the register permits *this* component. The register wins on any
  conflict.
- **Accessibility is composition, not review** (standing rule,
  [`GUIDE.interface-principles.md`](GUIDE.interface-principles.md)). The a11y column is part of
  the component, not a checklist run afterwards. WCAG 2.2 AA is the floor.

---

## The table

| Component | Copy from | States | A11y behaviour | Motion |
|---|---|---|---|---|
| **Button** | Marketing: `.btn` `.btn-primary` `.btn-ghost` `.btn-lg` (`marketing/web/v36-sharpened/site.css`) · App: the button family on [`../concepts/app-components.html`](../concepts/app-components.html) | rest · hover (primary: fill mixes toward bright ink; ghost: border + text brighten) · focus-visible (3px outer ring, `--rly-accent-ui` at reduced alpha) · aria-disabled (dimmed, click intercepted, the reason stated — see [`GUIDE.forms.md`](GUIDE.forms.md); live in [`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) F06; never a button that plays dead. **Known debt:** the stealth register's `relay-access.js` still sets the real `disabled` attribute on its submit — it predates this standard, per [`GUIDE.forms.md`](GUIDE.forms.md), and its fix path runs through production first) · pending (label changes to the working verb; never a spinner alone) | Real `<button>` or `<a>` — a link styled as a button stays a link. Target ≥44px. Label is the action verb, not "Submit". Filled buttons draw the **pair** — `--rly-accent-fill` + `--rly-on-accent`, never one half; contrast is asserted at token build (≥4.5:1 label-on-fill, both themes). One teal moment per object (DEC-025 L2): a card already showing a teal Code never also gets a teal button. | **Never animates, never moves.** Colour/border transitions only. No lift, no bounce, no flourish on the element — flourish goes *around* interactive elements. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **Form field** | `.f-field` (`marketing/web/v36-sharpened/site.css`) · live validation lifecycle: `marketing/web/stealth/relay-access.js` | rest · focus (border to bright ink + soft accent ring) · invalid (set **on submit**, cleared **on input**, focus moves to the first invalid field — `relay-access.js`; the `change`-for-selects clause is owned by [`GUIDE.forms.md`](GUIDE.forms.md) and lives in [`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) F02 — the stealth register wires no selects) | Visible `<label for>` on every control — a placeholder is never the label. Helper text is always-visible; `aria-describedby` wiring is the contract ([`GUIDE.forms.md`](GUIDE.forms.md), forms-kit F03) — **paid 2026-09-24 (site v3.6.4):** the v36-sharpened register's invite-code hint now carries `id="ic-hint"` and its control `aria-describedby="ic-hint"`. `autocomplete` on name/email. Errors are announced in text, never colour alone, and never discard what someone typed ([`GUIDE.states-and-language.md`](GUIDE.states-and-language.md)). | **Never animates, never moves.** The focus ring appears instantly; the error state toggles without transition. Ambient flourish may sit around a field only as the register permits, `aria-hidden`, never on it. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **Card** | App list row: [`GUIDE.list-surfaces.md`](GUIDE.list-surfaces.md) (anatomy law, DEC-025-governed) + the card specimens on [`../concepts/app-components.html`](../concepts/app-components.html) · Marketing: `.uc3-card`, `.platc` (`site.css`) | rest · hover (border brightens — decorative only; information never lives only in hover, rule 6) · focus-within (visible ring when the card's link holds focus) · resolved / selected on app surfaces | **A card with one destination is clickable everywhere, not just its link** (carried from the Execution Space estate — Erik, 2026-08-28: "this kinda card should always be clickable"). Whole-card `<a>`, or stretch the inner link over the card; repeated link text ("Open →") gets a per-card `aria-label`. Whole card is the tap target, ≥44px. Card head, badge rule and the one-teal-moment law stay owned by [`GUIDE.list-surfaces.md`](GUIDE.list-surfaces.md) — this row does not restate them. | Hover answer is colour/border only. Any entrance or ambient treatment sits behind/around the card, `aria-hidden`, within the page budget. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **Nav** | `.site-header` + `.nav` (`marketing/web/v36-sharpened/site.css`) — sticky bar, blur backdrop, burger panel ≤720px | rest · hover (link brightens) · current page · phone (burger opens the stacked panel; body links become full-width rows) | Links are links, in DOM order. Tap targets reach ≥44px by padding, not text height. Nothing in the nav is hover-gated (rule 6 corollary). One `<nav>` landmark. **Paid 2026-09-24 (site v3.6.4):** the burger's checkbox is now visually hidden but focusable and named (`aria-label="Menu"`) at ≤720px, its label carries the `:focus-visible` ring and a 44px target, and Space opens the panel — verified headless by keyboard. The CSS-only pattern stays (no-JS estate); a real `<button>` with `aria-expanded` is the upgrade if the nav ever gets script. | Colour transitions only. The bar never animates in or out and never hides on scroll. The panel opens without ceremony. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **CTA band** | `.cta` (`marketing/web/v36-sharpened/site.css`) — the same closing structure on every v36-sharpened page: `h2` + one paragraph + `.btn-primary` + mono `.cta-note`. Since v3.6.2 the close's *copy* varies per page (pricing closes on its placed keeper, "No tolls in. / No walls out."); the structure does not | Static — the interactive states live on its button. One band per page, always at the page end. | Real `<h2>`, one primary action, the qualifier in the note line — not in the button label. The band exists for the thumb rule: it repeats the primary action where a reader finishes, instead of sticky-chasing them down the page. | **Static by design.** The band's headline never animates and the page's one loud moment never lives here — a moving close is a close that competes with its own button. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **Forms kit** (lead capture) | Written contract: [`GUIDE.forms.md`](GUIDE.forms.md) · live wiring: `marketing/web/stealth/relay-access.js` | The full set — per-field errors, hints, autocomplete, bot-check gating (pending vs never-loaded), consent-locks-submit, the **optional ask that never gates** (piece 6b — same control, opposite behaviour; the MCP authorization screen's data-capture box is the live case), confirmation screen — is owned by [`GUIDE.forms.md`](GUIDE.forms.md). This table deliberately does not restate it: one law, one page. | Per [`GUIDE.forms.md`](GUIDE.forms.md). The confirmation's `role="status"` + focus move is live in [`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) F07 (`tabindex="-1"` + `.focus()` on arrival). The stealth register sets `role="status"` but makes no focus move — burn-down debt recorded in [`GUIDE.forms.md`](GUIDE.forms.md): the register predates the standard and its fix path runs through production first. | Fields and submit controls never animate; the confirmation replaces the form as a complete resting frame. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |
| **Landing-page family** | `marketing/web/v36-sharpened/index.html` and its sibling pages (`product/`, `platform/`, `pricing/`, `loop/`, `use-cases/`, `security/`, `register/`, `login/`) | The template is the sequence, read off `index.html` (v3.6.2 — the 2026-08-31 canon-content rebuild): sticky `.site-header` → `.hero` (hook — one `h1`, primary + ghost action, mono note, the `.oath` beat line) → the `.tools` works-with strip → a first full-bleed `.moment` band carrying the session frame ("Your session doesn't end when the window does") → `.keyebrow`-headed promise sections (Product loop → `.plat` Platform strip → the second-brain identity layer → the "Who it's for" segments teaser) → the `.moment` proof band ("Every hop, on the record") → the `.cta` band close. Two `.moment` bands per page is the rebuilt home's shape, not a licence — inner pages carry at most one. A `.faq` block joins the sequence only where a page owes straight answers (live on `loop/`). | Every section heading is a real `h2` under the hero's single `h1`; nav, form, button and CTA obligations are their own rows. Geo variants are deferred to un-stealth — recorded in [`../GUIDE.golive.md`](../GUIDE.golive.md). | Per the page budget — flourish lives in the hero and the proof band, never on interactive elements. [`GUIDE.motion-register.md`](GUIDE.motion-register.md) |

---

## Deeper contracts — owned elsewhere, pointed at from here

These are already behaviour law in their own documents. This table points; it does not fork.

| Contract | Owner |
|---|---|
| Slide-up sheet mechanic (`translateY`-only, never a route change), tools panel, bell, menu, capture bar, segmented control | [`../briefs/SPEC.app-components.md`](../briefs/SPEC.app-components.md) |
| Approve/Deny — primary single-tap, Deny gets a 5s undo, never symmetric weight | [`../briefs/SPEC.app-components.md`](../briefs/SPEC.app-components.md) |
| The five relay-app primitives (Sheet, ControlRow/FilterSheet, ObjectCard, StateBlock, Rail), build order and APIs | [`../briefs/BRIEF.ui-component-kit.md`](../briefs/BRIEF.ui-component-kit.md) |
| Card head, Code presence, badge rule (DEC-025 L1–L4) | [`GUIDE.list-surfaces.md`](GUIDE.list-surfaces.md) · [`../briefs/BRIEF.code-presence.md`](../briefs/BRIEF.code-presence.md) |
| Empty / error / loading state copy | [`GUIDE.states-and-language.md`](GUIDE.states-and-language.md) |
| Analytics hooks — `data-rly-*` in the component's own markup, never bound to a styling class | [`../briefs/BRIEF.ui-component-kit.md`](../briefs/BRIEF.ui-component-kit.md) §5b |

---

## Tokens — how a consuming team gets the values

Every value in the table's specimens resolves to a token. The contract for any team consuming
these components:

- **Source:** `brand/relay-design-tokens.json` is the only token file a human edits.
  `scripts/build_tokens.py` emits the adapters into `brand/generated/` — the `--rly-*`
  stylesheet this estate links, relay-app's bare `--*` set, React Native constants, MkDocs, and
  literal-hex constants for email. The build refuses to emit a palette that fails its own
  contrast invariants (label-on-fill ≥ 4.5:1 **and** fill-on-page ≥ 3:1, both themes).
- **App teams map the same token names into their platform** — consume the emitted adapter for
  your platform; never fork the values, never hand-copy a hex. When a value looks wrong, the
  fix goes into the JSON source and re-emits everywhere.
- **The consumers are owned by another session.** This repo emits the adapters and never edits
  relay-app or relay-platform (EC, 2026-08-26: *"you do NOT touch anything outside of creative
  for that token change"*). If an adapter is missing something a consumer needs, the ask comes
  back here.
- **Precedence:** tokens win on any *value* conflict; this document wins on *behaviour*.

---

## Amending

Same house rule as every guideline: a row earns its place by catching a real defect. Change the
table in a PR with the `EXP-nnn` finding or the dated quote that prompted it, add a line to
[`CHANGELOG.md`](CHANGELOG.md) in the same PR, and update `MANIFEST.md` if it changes what's
canonical. Reviewing against this table, name the row and the column — "the card fails its a11y
cell: the link doesn't stretch" is actionable; "the card feels wrong" is not.

One open pick, on EC: the marketing button vocabulary (`.btn-primary` / `.btn-ghost`) and the
app-concept button vocabulary (accent / ghost / danger) are two naming families for the same
contract. The table records both; unifying the names is a decision, not a drift to absorb.
