# Hearth: implementation guide

Generated by `npm run guide:generate` from the component registry, shared brief, and token source. Do not edit this file by hand. Site links are relative to the running application. Repository paths are relative to the repository root.

## Start here

Read `AGENTS.md` first; its repository rules still apply. Read `lib/contract/schema.ts` before changing household behaviour. The source code and exported TypeScript props remain the API authority; the contracts below provide usage guidance.

- Component contracts: `design-system/docs/registry.ts`
- Live examples: [Design system](/system)
- Token source: `design-system/tokens/tokens.json`
- Outcomes, time, and summary copy: `lib/engine/index.ts`
- Shared editor state: `lib/state/contract-store.ts`
- Worked composition: `components/activity/ActivityRecap.tsx`

## Workflow

1. **Find the contract.** Read when to use a component, when to avoid it, and its live states before choosing it.
2. **Compose what exists.** Use the existing components and tokens. Record a missing requirement before adding a variant.
3. **Keep decisions in the engine.** Pass evaluated outcomes through unchanged. The interface presents the result and the reason behind it.
4. **Check the result.** Verify behaviour, keyboard access, narrow layouts, and both themes. Report the checks that actually ran.

## Token vocabulary

Use semantic colours in both themes; never add a separate dark palette in a component. Accent is reserved for open household asks and approvals.

- Colours: `bg`, `surface`, `raised`, `sunken`, `ink`, `ink-2`, `ink-3`, `line`, `line-strong`, `accent`, `accent-strong`, `accent-tint`, `on-accent`, `acted`, `acted-tint`, `asked`, `asked-tint`, `downgraded`, `downgraded-tint`, `expired`, `expired-tint`, `refused`, `refused-tint`, `focus`, `paper-green`, `paper-blue`, `paper-clay`, `illustration`, `illustration-paper`
- Type sizes: `text-xs`, `text-sm`, `text-base`, `text-md`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-hero`
- Font families: `font-display`, `font-sans`
- Weights: `font-regular`, `font-medium`, `font-semibold`
- Line height: `leading-tight`, `leading-snug`, `leading-normal`
- Tracking: `tracking-tight`, `tracking-normal`, `tracking-wide`

Use `bg-surface`, `text-ink-2`, `rounded-lg`, and token motion values. Read the token source for spacing, radii, shadows, and motion. Opacity modifiers on tokens are supported.

## Worked-example brief

Create a read-only grocery activity recap showing recent actions, a receipt, and links to the rules that governed them.

Start with AGENTS.md and public/design-system/implementation-guide.md. Read the relevant component contracts and their live examples under /system.

Use the Grocery Restocker fixture at fixture.defaultNow. Evaluate the complete fixture before selecting recent activity. Use the engine for selection, statuses, summaries, dates, deadlines, and contract links. Reuse RiskSummary, TraceEvent, ApprovalCard, Receipt, and ContractLink with design tokens.

Show the latest three attempts that have occurred, any currently open ask without approval buttons, and the latest itemised receipt from work done on its own. Keep quiet receipts labelled. Do not show future activity or change the saved household note.

Document the composition and its mid-week, end-of-week, quiet-receipt, and empty states. Verify at 390, 768, and 1440px in light and dark themes, including keyboard links and reduced motion. Run the repository checks and report results, limitations, and any unsupported requirements.

[Live result](/system/build-with-ai#worked-example) · [All recap states](/system/activity-recap) · [Recorded verification](/design-system/verification.md)

## Verification

```sh
npm run guide:generate
npm test
npm run lint
npm run typecheck
npm run build
npm run test:ai-ready
npm run test:polish
npm run audit:a11y
```

Lint includes guide freshness and the focused token audit. Browser checks run against an owned production server after the build. The token audit checks literal colours, default palette utilities, and unsupported typography in TSX strings and CSS declarations under app, components, and design-system (excluding the generated token CSS). It cannot establish component reuse, engine correctness, computed runtime styles, or visual quality; review those separately. Report failures and unsupported requirements explicitly.

## ActivityRecap

A read-only snapshot of recent household activity, an open question, and an itemised receipt from work done on its own.

Source: `components/activity/ActivityRecap.tsx`

```tsx
import { ActivityRecap } from "@/components/activity/ActivityRecap";
```

[Live states and usage contract](/system/activity-recap)

### When to use

- For a fixed snapshot of a supplied note and fixture.
- As the worked composition in Build with AI.

### When not to use

- For answering asks or editing the household note. Use the editor and replay.
- For a complete history or a spending total. This shows three recent attempts and one unattended receipt.

### Props

- **template** (required): `Template`. The supplied contract and complete intent-only fixture; never a pre-filtered fixture.
- **now** (required): `number`. A fixed instant parsed by the engine. Future events are excluded.
- **className**: `string`. Layout classes using design tokens.

### Accessibility invariants

- Every trace includes a text status and a glyph, not colour alone.
- Contract links are keyboard accessible and retain their complete names.
- The open question is read-only: no approval or decline handlers are passed.
- Quiet receipts explain the policy in text. Empty periods have an explicit message.
- Use beneath a section heading; the recap title is h3 and its subsections are h4.

### Implementation instructions

Evaluate the complete supplied contract and fixture, then call activityRecap(run, now). Pass statusAt(event, now) to TraceEvent and describeLink(event, contract) to the existing link props. Pass the unmodified event to ApprovalCard so its deadline comes from decision.deadline. Receipt is deliberately limited to the latest itemised unattended action; it is not a spending summary. Do not read or write the saved-contract store in this fixed example. Preserve every documented state.

### States

- Mid-week
- End of week
- Quiet receipt
- Empty period

---

## SurfaceState

Shared page loading, recovery, and empty feedback.

Source: `components/SurfaceState.tsx`

```tsx
import { SurfaceState } from "@/components/SurfaceState";
```

[Live states and usage contract](/system/surface-state)

### When to use

- At a route loading or error boundary.
- When a page has no content to show.

### When not to use

- For household outcomes, asks, or receipts. Those come from the engine.

### Props

- **state** (required): `"loading" | "error" | "empty"`. Page availability only.
- **onRetry**: `() => void`. Pass the route error boundary’s retry callback.
- **className**: `string`. Additional layout classes.

### Accessibility invariants

- WorkingIndicator announces availability politely.
- Retry and home are native keyboard actions.
- Reduced motion disables the loading dot’s animation.
- Never display internal error details.

### Implementation instructions

Reuse the root loading and error boundaries for gallery, editor, replay, and system routes. Do not invent delays or failure flags in product routes. The live examples render the actual loading fallback and exercise an error boundary using the actual recovery fallback.

### States

- Loading
- Error and retry
- Empty

---

## TemplateCard

A helper’s starting note, followed by the last few moments of its evaluated household example.

Source: `components/templates/TemplateCard.tsx`

```tsx
import { TemplateCard } from "@/components/templates/TemplateCard";
```

[Live states and usage contract](/system/template-card)

### When to use

- In the household helper gallery, one card per template.
- When comparing what different starting contracts permit.

### When not to use

- For the person’s edited contract; the editor and full replay share that state.
- For live work, new approvals, or a full activity timeline.

### Props

- **template** (required): `Template`. The starting contract and complete intent-only fixture. Mount with key={template.id} when changing helpers.
- **readOnly**: `boolean`. Disables hover and button playback, preserving navigation.
- **initialNow**: `number`. Optional initial snapshot for a documented state. Normally shows the fixture’s end.
- **className**: `string`. Layout classes from design tokens.

### Accessibility invariants

- The article is named by its visible helper heading.
- Play/Pause is a native button with aria-pressed; keyboard and touch do not depend on hover.
- Leaving the card, moving focus outside it, or hiding the page pauses playback. Playback stops at the end without looping.
- Reduced motion disables automatic hover playback; the explicit play button remains available.
- Compact events have accessible status, time, and full action names, plus a glyph.
- Contract and replay destinations are separate links, never nested inside a clickable card.

### Implementation instructions

Compute summarize(template.contract) and evaluate(template.contract, template.fixture). Pass the summary unchanged to compact RiskSummary. Take tracePreview from the complete evaluated run so the weekly ledger is preserved, then advance only now with advancePreview. Each compact TraceEvent must receive statusAt(event, now); never assign colours or outcomes in the card. Do not read the edited-contract store here or add per-template behaviour copy. Keep the contract link first and avoid decorative hover motion.

### States

- Groceries
- Shared expenses
- Subscriptions
- Paused during an ask
- No spending limit
- No activity
- Read-only

---

## ActivityReplay

A practice week with scrubbing, playback, event details and a comparison against your saved note.

Source: `components/activity/ActivityReplay.tsx`

```tsx
import { ActivityReplay } from "@/components/activity/ActivityReplay";
```

[Live states and usage contract](/system/activity-replay)

### When to use

- On a helper’s Activity route, below the shared header and navigation.
- For a simulated fixture whose outcomes can be re-evaluated from a contract.

### When not to use

- For live approvals or real orders.
- For the compact preview on a gallery card; use TraceEvent there.

### Props

- **template** (required): `Template`. Default contract and intent-only fixture. Mount with key={template.id} when changing templates.
- **initialNow**: `number`. Optional initial playhead instant; normally use the fixture default.
- **initialWhatIf**: `boolean`. Start with the edited contract visible. Defaults to false.

### Accessibility invariants

- Playback starts only on request and stops when scrubbing, selecting an event, or switching the comparison.
- Timeline markers are keyboard buttons; the playhead is a named slider.
- Phone selection opens a Sheet with focus return and Escape dismissal.
- Changed events have an announced label and rings, with previous status text in the trace.
- Reduced motion is respected by the existing timeline and glyph primitives.

### Implementation instructions

Use useEditableContract(template), exactly as the editor does. Evaluate the default and shared edited contracts against the same fixture; use diffRuns for changedIds and statusAt(baseEvent, now) for changedFrom. Never make a draft contract, calculate outcomes, count changes, or assign event colours in this component. Keep all approval cards read-only. Group days and advance through transitions with the engine helpers. This is an application composite documented here for reuse.

### States

- Original note, mid-week
- What-if, saved $60 ceiling
- Empty period

---

## ActivityDetail

The selected event’s explanation and contract link, shared by a panel and a phone sheet.

Source: `components/activity/ActivityDetail.tsx`

```tsx
import { ActivityDetail } from "@/components/activity/ActivityDetail";
```

[Live states and usage contract](/system/activity-detail)

### When to use

- For the selected event in ActivityReplay, at the same playhead instant as the timeline.

### When not to use

- For a list row or compact trace.
- For accepting new answers; fixture answers remain read-only.

### Props

- **event / contract / now** (required): `EvaluatedEvent / Contract / number`. From the displayed run and the shared playhead.
- **editorHref** (required): `string`. Use contractHref(template.id). The event’s engine link adds its anchor.
- **baseEvent**: `EvaluatedEvent`. Pass only when diffRuns marks this event changed and what-if is on.

### Accessibility invariants

- Outcome text and glyph accompany colour.
- Upcoming events never render a completed receipt or recorded response.
- Every record includes an independently focusable contract link.
- The caller provides the panel or Sheet landmark and owns selection and focus.

### Implementation instructions

Read statusAt and outcomeLabel; branch on the evaluated decision only to choose a view. Pass decision.deadline through ApprovalCard and receipt policy through Receipt. Build links with describeLink and always provide linkToneFor(event). Use describeChange for the comparison sentence. Do not reconstruct event decisions or introduce a second now value.

### States

- Coming up
- Done
- Quiet receipt
- Waiting on you
- You said yes, limit hit
- You said no
- No answer
- Expired, late answer
- No answer, went ahead
- Refused by rule
- Never
- Changed under what-if

---

## AutonomyLadder

The three-rung control every capability sits on: Do it, Ask first, Never.

Source: `design-system/ai/AutonomyLadder.tsx`

```tsx
import { AutonomyLadder } from "@/design-system/ai/AutonomyLadder";
```

[Live states and usage contract](/system/autonomy-ladder)

### When to use

- For every capability in a contract, one ladder each. It is the primary control in the editor.
- Read-only, wherever a capability's rung needs to be shown next to something it governed.

### When not to use

- For on/off settings that are not about autonomy. Use Toggle.
- For hard rules. A hard rule is never a rung; it is a HardRule.
- For anything with more or fewer than three options. The ladder is the ladder.

### Props

- **label** (required): `string`. The capability as a household member would say it: "Reorder a staple".
- **value** (required): `"do" | "ask" | "never"`. The current rung.
- **onChange**: `(value) => void`. Omit for a display-only ladder; pass readOnly too.
- **defaultValue**: `Autonomy`. The template default. Shows "Changed from Ask first" with a one-tap reset.
- **description**: `string`. One plain sentence about what the capability covers.
- **id**: `string`. Anchor id, `cap-<capabilityId>`, so contract links can scroll here.
- **children**: `ReactNode`. Ceilings go here. They render beneath a divider.
- **disabled / readOnly / highlighted / size**: `boolean | "sm" | "md"`. State flags. `highlighted` is for the moment a contract link lands here.

### Accessibility invariants

- A radiogroup labelled with the capability. Each rung is a radio with a hidden hint ("Prepares it, then waits for you").
- Roving tabindex: Tab lands on the selected rung; arrow keys and Home/End move between rungs and change the value.
- Colour is never the only signal: the selected rung carries its glyph, and the label is bold.
- The moving thumb is decorative and respects reduced motion.

### Implementation instructions

Render one AutonomyLadder per capability, in the order the template lists them. Always pass label and value; pass onChange unless the surface is read-only, in which case pass readOnly. Put that capability's Ceiling components as children so they sit under their rung. Give it id="cap-" + capability.id so links from the timeline can find it. Do not add extra options, colour the rungs yourself, or use it for anything that is not a capability's autonomy.

### States

- Do it
- Ask first
- Never
- Changed from default
- With ceilings beneath
- Small
- Read-only
- Disabled
- Highlighted (linked to)

---

## Ceiling

A numeric limit on a Do-it capability that reads as a sentence and adjusts as a slider.

Source: `design-system/ai/Ceiling.tsx`

```tsx
import { Ceiling } from "@/design-system/ai/Ceiling";
```

[Live states and usage contract](/system/ceiling)

### When to use

- Under an AutonomyLadder, one per ceiling the template defines for that capability.
- In a detail panel with `check`, to show how close an action came to the limit.

### When not to use

- On capabilities set to Ask first or Never. Render it disabled with a reason instead of hiding it, so the user learns it exists.
- For anything that is not a number per action, per day or per week. Hard limits in words are HardRules.

### Props

- **ceiling** (required): `Ceiling`. The schema object: measure, scope, limit, range, unit.
- **onChange**: `(limit: number | null) => void`. null means no limit. The component offers that as an explicit button, never as the end of the slider.
- **disabled + disabledReason**: `boolean, string`. "Only applies while this is set to Do it."
- **check**: `CeilingCheck`. From the engine. Shows the measured value and whether it was over.
- **id**: `string`. Anchor id, `ceiling-<ceilingId>`.
- **readOnly / highlighted**: `boolean`. State flags.

### Accessibility invariants

- A native range input, so keyboard and assistive tech work without extra code.
- aria-valuetext is the full sentence ("up to $40 an order"), not the bare number.
- The sentence above the slider is aria-live, so changing the value is announced once.
- "No limit" is a warning state with text and an icon, not just a colour.

### Implementation instructions

Render a Ceiling for each entry in capability.ceilings, as children of that capability's AutonomyLadder. Pass the ceiling object straight from the contract; do not reformat the limit yourself, the component builds the sentence. When the capability is not on Do it, keep the Ceiling but pass disabled with disabledReason. Use id="ceiling-" + ceiling.id. Never hide the no-limit state or restyle it as neutral.

### States

- Money per order
- Money per week
- Count per day
- Days (same day only)
- No limit
- Disabled (not on Do it)
- With a replay check, under
- With a replay check, over
- Read-only
- Highlighted (linked to)

---

## HardRule

A plain-language never that takes a deliberate hold or explicit acknowledgement to turn off and one tap to turn back on.

Source: `design-system/ai/HardRule.tsx`

```tsx
import { HardRule } from "@/design-system/ai/HardRule";
```

[Live states and usage contract](/system/hard-rule)

### When to use

- One per hard rule in the contract, in a list under a "Never" heading.
- Read-only wherever a refusal needs to show the rule that caused it.

### When not to use

- For anything the user should be able to flip casually. That is a Toggle or a ladder rung.
- For limits with a number in them. Those are Ceilings.

### Props

- **rule** (required): `HardRule`. text, why, forbids, enabled.
- **onChange**: `(enabled: boolean) => void`. Called after a completed hold or acknowledged confirmation (off), or a single tap (on).
- **id**: `string`. Anchor id, `rule-<ruleId>`.
- **defaultConfirming / defaultAlternative**: `boolean`. Initially expand confirmation and its click alternative; for state demos.
- **readOnly / highlighted**: `boolean`. State flags.

### Accessibility invariants

- Keep the one-second pointer/keyboard hold. The alternative path uses native click activation, an acknowledgement checkbox, and an explicit confirmation button for assistive technology.
- An incomplete hold cancels on blur. Completing a change returns focus to the rule action.
- The rule's reason is shown before the destructive control appears, so a screen-reader user hears why first.
- Off is shown with a strike-through, a chip that says Off, and an open-lock glyph. Never only a colour.
- Turning back on is a plain button. Undoing should always be easier than doing.

### Implementation instructions

List HardRule components for contract.hardRules under a heading such as "Never". Pass the rule and an onChange that calls setHardRule from lib/contract/update. Do not wrap it in a Toggle, do not add a confirm dialog of your own, and do not offer a way to edit the rule's wording; only enabled changes. Use id="rule-" + rule.id.

### States

- On
- Confirming
- Click confirmation
- Off
- Read-only
- Highlighted (linked to)

---

## RiskSummary

What the contract adds up to, in a handful of sentences that recompute as the contract changes.

Source: `design-system/ai/RiskSummary.tsx`

```tsx
import { RiskSummary } from "@/design-system/ai/RiskSummary";
```

[Live states and usage contract](/system/risk-summary)

### When to use

- At the top of the editor, full variant, always visible.
- On gallery cards and in headers, compact variant: the headline only.

### When not to use

- To explain a single event. Use ContractLink for that.
- As a place for warnings the engine did not produce. It renders the engine's summary, nothing else.

### Props

- **summary** (required): `RiskSummary`. From summarize(contract). Never hand-built.
- **variant**: `"full" | "compact"`. Compact is one paragraph with a tone mark.
- **onJump**: `(anchor: string) => void`. Each line carries the editor anchor it is about; this scrolls there.

### Accessibility invariants

- A labelled section. Lines are a list, so a screen reader reports how many there are.
- Tone is carried by a word (Calm, Worth a look, Watch out) and a glyph, never only colour.
- Text changes crossfade rather than flash; with reduced motion they simply swap.

### Implementation instructions

Compute the summary with summarize(contract) from lib/engine on every change and pass it in. Render the full variant once at the top of any contract editor and keep it in view; render compact on cards. Wire onJump to scroll to the anchor, which matches the ids used by AutonomyLadder, Ceiling and HardRule. Never write your own summary sentences.

### States

- Calm
- Worth a look
- Watch out (no limit)
- Watch out (no hard limits)
- Compact
- Live: recomputes as you change a ceiling

---

## ContractLink

The pill that connects an outcome to the contract line that produced it.

Source: `design-system/ai/ContractLink.tsx`

```tsx
import { ContractLink } from "@/design-system/ai/ContractLink";
```

[Live states and usage contract](/system/contract-link)

### When to use

- On every TraceEvent, ApprovalCard and Receipt.
- Anywhere an outcome is shown away from the editor.

### When not to use

- As a general-purpose tag or chip.
- To link to anything other than a contract line.

### Props

- **link** (required): `ContractLink`. From describeLink(event, contract): prefix, parts, text, anchor.
- **tone** (required): `"acted" | "asked" | "downgraded" | "refused"`. Use linkToneFor(event). Never infer a tone from the link text.
- **href**: `string`. The editor route. The anchor is appended as a hash. Without href it renders as a button.
- **onClick / size**: `() => void, "sm" | "md"`.

### Accessibility invariants

- The accessible name is the full sentence plus "Open this line in the contract".
- Renders as a real link when it navigates and a real button when it does not.
- Text changes under what-if crossfade and are announced by the surrounding aria-live region, not by the pill.

### Implementation instructions

Whenever you show an evaluated event, call describeLink(event, contract) and render a ContractLink with that result, tone from linkToneFor(event), and href pointing at the template's editor route. Do not compose the sentence yourself and do not change the prefix wording (Allowed by, Asked because, Refused by, Blocked by).

### States

- Allowed by (acted)
- Asked because (Ask first)
- Asked because (limit hit)
- Refused by (hard rule)
- Blocked by (Never)
- Small
- As a button

---

## TraceEvent

One thing the helper did, asked, or refused, as a row in a list.

Source: `design-system/ai/TraceEvent.tsx`

```tsx
import { TraceEvent } from "@/design-system/ai/TraceEvent";
```

[Live states and usage contract](/system/trace-event)

### When to use

- In the activity list next to the Timeline, one per event.
- Compact, as the mini trace on a gallery card.

### When not to use

- As the detail view. That is an ApprovalCard or a Receipt.
- For anything that is not an evaluated event from the engine.

### Props

- **event** (required): `EvaluatedEvent`. From the engine.
- **status** (required): `Status`. From statusAt(event, now). The row does not know the time itself.
- **link + linkHref**: `ContractLink, string`. Renders a ContractLink under the detail.
- **selected / onSelect**: `boolean, () => void`. Makes the row a pressable button; the link stays independently clickable.
- **changedFrom**: `Status`. The status under the previous contract; shown struck through.
- **variant**: `"row" | "compact"`.

### Accessibility invariants

- When selectable, the whole row is one button with an accessible name of status, time and action.
- The status word is always present in text; the glyph and colour reinforce it.
- Quiet events (no receipt) say so in words.

### Implementation instructions

Map run.events to TraceEvent rows, computing status with statusAt(event, now) where now is the playhead. Pass link from describeLink and linkHref for the editor. Under a what-if, pass changedFrom with the status from the baseline run. Use variant="compact" only for previews with three to five rows.

### States

- Done
- Asked you
- Waiting on you
- No answer
- Refused
- Limit hit
- Coming up
- Selected
- Changed under what-if
- Quiet (no receipt)
- Compact

---

## Timeline

The week as a strip: day columns, one marker per event, and a playhead you can drag.

Source: `design-system/ai/Timeline.tsx`

```tsx
import { Timeline } from "@/design-system/ai/Timeline";
```

[Live states and usage contract](/system/timeline)

### When to use

- Once per replay, above the list and detail panel.
- Read-only (no onNowChange) when the period is a fixed snapshot.

### When not to use

- For fewer than three events; a list is clearer.
- For periods longer than about two weeks; the markers get too dense at phone width.

### Props

- **events** (required): `EvaluatedEvent[]`. From the engine. Any order; the timeline sorts.
- **startsAt / endsAt** (required): `number`. Instants from parseWallClock.
- **now** (required): `number`. The playhead. Everything after it is upcoming.
- **onNowChange**: `(now: number) => void`. Enables dragging and keyboard scrubbing.
- **selectedId / onSelect**: `string, (id) => void`. Marker selection.
- **changedIds**: `Set<string>`. Markers whose outcome changed under what-if get a ring.

### Accessibility invariants

- The playhead is a slider: arrow keys move an hour, Shift six hours, PageUp/PageDown a day, Home/End to the ends. Its value text is the full date and time.
- Markers are native buttons in a toolbar with roving tabindex and aria-pressed; arrow keys move between events in time order. Changed outcomes are included in their accessible names.
- Every marker's name is its date, action and status. Colour and glyph are reinforcement.
- Drag uses pointer events with capture, so it works with touch and mouse; the strip is touch-action none.

### Implementation instructions

Render one Timeline per replay with events from the engine run, startsAt/endsAt from the fixture, and now from state that the ApprovalCard and TraceEvent list also read. Pass onNowChange so the user can scrub, and onSelect so the detail panel follows. Under a what-if, pass changedIds from diffRuns. Do not position or colour markers yourself; do not add a second playhead.

### States

- A full week, mid-week
- Dragging the playhead
- With a selection
- With what-if changes ringed
- Read-only
- Empty

---

## ApprovalCard

The ask as you see it: what it wants, why it stopped, how long it will wait, and afterwards what you said.

Source: `design-system/ai/ApprovalCard.tsx`

```tsx
import { ApprovalCard } from "@/design-system/ai/ApprovalCard";
```

[Live states and usage contract](/system/approval-card)

### When to use

- In the detail panel for any event whose decision is asked.
- Live, with onApprove/onDecline, in a real inbox.

### When not to use

- For events that were done or refused. Use Receipt, or the TraceEvent detail.
- As a notification toast. It is a full card.

### Props

- **event** (required): `EvaluatedEvent`. Must have decision.kind === "asked"; otherwise renders nothing.
- **escalation** (required): `Escalation`. From the contract, for the channel and deadline copy.
- **now** (required): `number`. The playhead. Decides whether the ask is open or answered.
- **onApprove / onDecline**: `() => void`. Both together make the buttons live while the ask is open.
- **link / linkHref**: `ContractLink, string`. Footer link to the governing line.

### Accessibility invariants

- An article named by the heading and the action.
- The primary button is the only accent-coloured control in the product and only appears while the ask is open.
- A limit hit is a chip with a glyph and words ("$55, over $40 an order").
- What happens on no answer is always stated in text, with "go ahead anyway" in the refused tone.

### Implementation instructions

Show an ApprovalCard when the selected event's decision.kind is "asked". Pass the contract's escalation and the same now the Timeline uses. In a replay do not pass onApprove/onDecline; the card then shows the recorded answer once now passes it. In a live inbox pass both. Never render approve/decline for a resolved ask.

### States

- Waiting on you (live buttons)
- Waiting on you (replay)
- Limit hit, approved
- Declined
- Expired, let it go
- Expired, went ahead
- Expired with a late answer
- Coming up

---

## Receipt

What the helper did, itemised, with the line that allowed it.

Source: `design-system/ai/Receipt.tsx`

```tsx
import { Receipt } from "@/design-system/ai/Receipt";
```

[Live states and usage contract](/system/receipt)

### When to use

- In the detail panel for any event that was done, on its own or after a yes.
- In a receipts feed.

### When not to use

- For asks or refusals.
- For money the helper did not move. A split it recorded is not a receipt for spending.

### Props

- **event** (required): `EvaluatedEvent`. Uses source.lines for the itemisation.
- **policy**: `ReceiptPolicy`. The contract's receipt setting, quoted when the receipt is quiet.
- **link / linkHref**: `ContractLink, string`. Footer link.

### Accessibility invariants

- Named "Receipt: <action>". Lines are a list with amounts in tabular figures.
- A quiet receipt (one the policy would not have sent) is dashed, says so in words, and is still readable.
- Savings are negative amounts in the acted tone, with the minus sign present.

### Implementation instructions

Render a Receipt for events whose decision is acted, or asked and approved (or expired and proceeded). Pass the contract's receipts policy so quiet receipts explain themselves. Use the fixture's lines as they are; do not total them yourself.

### States

- Itemised, with total
- Single line
- No money (a coupon, a slot change)
- After you said yes
- Quiet (not sent under the policy)

---

## WorkingIndicator

The helper is doing something. A breathing dot and a sentence, never a spinner.

Source: `design-system/ai/WorkingIndicator.tsx`

```tsx
import { WorkingIndicator } from "@/design-system/ai/WorkingIndicator";
```

[Live states and usage contract](/system/working-indicator)

### When to use

- Wherever the helper is mid-task, with steps that say what it is doing.
- For idle, done, waiting and the one error state.

### When not to use

- For page loading. Use a skeleton.
- For progress with a known end. Use a count in text.

### Props

- **state** (required): `"working" | "waiting" | "done" | "idle" | "error"`.
- **label** (required): `string`. A sentence in the helper's voice: "Checking Greenleaf's stock".
- **steps**: `string[]`. While working, these rotate every two seconds.
- **detail**: `string`. Second line: "Will try again at 8 am."

### Accessibility invariants

- role=status with aria-live polite, so changes are announced without interrupting.
- The breathing dot stops under reduced motion; the text carries the meaning.
- Error uses the refused tone, a warning glyph, and says what happens next.

### Implementation instructions

Use WorkingIndicator instead of any spinner or progress bar. Write label and steps as short sentences a person would say about a chore. Always give the error state a detail line saying what happens next. Use state="waiting" only when the helper is waiting on the user.

### States

- Working (cycling steps)
- Waiting on you
- Done
- Idle
- Error
- Small

---

## ImplementationBrief

A selectable brief for implementing a screen, with a copy action and a manual fallback.

Source: `design-system/docs/ImplementationBrief.tsx`

```tsx
import { ImplementationBrief } from "@/design-system/docs/ImplementationBrief";
```

[Live states and usage contract](/system/implementation-brief)

### When to use

- In design-system documentation when handing a complete implementation brief to a coding assistant.

### When not to use

- For editable household notes or approval questions.
- For a general-purpose rich text editor.

### Props

- **text** (required): `string`. The complete brief. Keep its source shared with the generated guide.
- **initialState**: `"ready" | "copied" | "manual"`. Initial feedback for documentation states only. Omit in normal use.
- **className**: `string`. Layout classes using design tokens.

### Accessibility invariants

- The read-only textarea has a visible label and supports native selection.
- The copy action is a native Button and keeps focus on success.
- A polite status announces the result. Failure focuses and selects the full brief for manual copying.
- Copy uses a neutral button; accent remains reserved for household asks.

### Implementation instructions

Use the shared IMPLEMENTATION_BRIEF from design-system/docs/build-with-ai.ts. Await clipboard success before showing Copied. Keep the selectable textarea available even when clipboard permissions or browser support are missing. Never add a permission dialog or hide the text behind the copy action.

### States

- Ready
- Copied
- Manual copy

---

## HouseholdIllustration

Three quiet still lifes for the ordinary work of a household.

Source: `design-system/HouseholdIllustration.tsx`

```tsx
import { HouseholdIllustration } from "@/design-system/HouseholdIllustration";
```

[Live states and usage contract](/system/household-illustration)

### When to use

- To identify a helper in the gallery or its page header.
- As a supporting image alongside a meaningful heading.

### When not to use

- For an event status, permission, warning, or receipt.
- As the only way to identify a helper.

### Props

- **kind** (required): `"groceries" | "shared" | "subscriptions"`. Decorative identity, independent of the contract.
- **className**: `string`. Size and layout; preserve the SVG aspect ratio.

### Accessibility invariants

- Always decorative: aria-hidden and not focusable.
- Pair with visible identifying text.
- All fills and strokes resolve to token variables or currentColor.
- No animation, interaction, or embedded text.

### Implementation instructions

Use the same illustration for a helper wherever it appears. Do not use illustration colors for statuses. Keep the three palettes in tokens.json and let CSS variables handle both themes.

### States

- Groceries
- Shared expenses
- Subscriptions

---

## Button

One button, five jobs. Accent only for the thing that needs you.

Source: `design-system/primitives/Button.tsx`

```tsx
import { Button } from "@/design-system/primitives/Button";
```

[Live states and usage contract](/system/button)

### When to use

- Actions. Primary for the one main action on a surface, secondary for the rest, ghost for quiet ones.

### When not to use

- Navigation that reads as a link. Use a link.
- Turning a setting on or off. Use Toggle.

### Props

- **variant**: `primary | secondary | ghost | accent | danger`. accent is reserved for approving an ask; danger for turning protection off.
- **size / icon / iconEnd / href / working / full**. href makes it a link that looks like a button.

### Accessibility invariants

- Real buttons or real links; never a div.
- working sets aria-busy and blocks clicks.
- Focus ring comes from the global focus style.

### Implementation instructions

Default to secondary. Use exactly one primary per surface. Use accent only inside ApprovalCard or an equivalent needs-you moment. Never use accent for save, submit or continue.

### States

- Primary
- Secondary
- Ghost
- Accent
- Danger
- With icon
- Small
- Working
- Disabled
- As link

---

## Card

A quiet paper surface: precise edges, restrained rounding, and minimal elevation.

Source: `design-system/primitives/Card.tsx`

```tsx
import { Card } from "@/design-system/primitives/Card";
```

[Live states and usage contract](/system/card)

### When to use

- Grouping related content that stands on its own.

### When not to use

- Nesting inside another card. Use a divider or the raised tone instead.

### Props

- **padding / interactive / highlighted / tone**. interactive strengthens the border; wrap the content in a real link or button yourself.

### Accessibility invariants

- Purely presentational. Interactivity must come from a child link or button.

### Implementation instructions

Wrap standalone content. Do not nest cards. For a clickable card, put a link inside and set interactive.

### States

- Default
- Raised tone
- Interactive
- Highlighted
- No padding

---

## Sheet

A modal surface: bottom sheet on phones, side panel on wider screens.

Source: `design-system/primitives/Sheet.tsx`

```tsx
import { Sheet } from "@/design-system/primitives/Sheet";
```

[Live states and usage contract](/system/sheet)

### When to use

- Event detail on a phone.
- Anything that needs the user's full attention briefly.

### When not to use

- For confirmation of a hard rule. Use HardRule’s built-in hold or acknowledgement path.

### Props

- **open / onClose / title / side**. side="panel" slides from the right on wide screens.

### Accessibility invariants

- role=dialog, aria-modal, labelled by its title.
- Focus moves in on open, is trapped, and returns on close. Escape and the backdrop close it.
- Body scroll is locked while open.

### Implementation instructions

Use for transient detail. Always pass a title. Keep one sheet open at a time. Background branches become inert and regain their prior state on close.

### States

- Open (panel)
- Open (bottom)

---

## Tabs

A tab list with a sliding underline.

Source: `design-system/primitives/Tabs.tsx`

```tsx
import { Tabs } from "@/design-system/primitives/Tabs";
```

[Live states and usage contract](/system/tabs)

### When to use

- Switching between views of the same thing: Contract and Activity.

### When not to use

- For unrelated destinations; use the app navigation.
- For more than five items.

### Props

- **tabs / value / onChange / label / size**. label names the tab list for assistive tech. For route navigation, give every item href and omit value/onChange; selection comes from the current pathname.

### Accessibility invariants

- Local views use role=tablist with roving tabindex; arrow keys, Home and End move.
- Route items render real links inside a named nav, with aria-current=page for the active route.

### Implementation instructions

Keep labels to one or two words. For routes, give every item href and omit value/onChange. For local views, provide value/onChange and pair with TabPanel. Never mix links and local tabs in one list.

### States

- Default
- Small
- With badges
- Route navigation

---

## ChoiceGroup

Pick one of a few: cards for choices with consequences, chips for quick ones.

Source: `design-system/primitives/ChoiceGroup.tsx`

```tsx
import { ChoiceGroup } from "@/design-system/primitives/ChoiceGroup";
```

[Live states and usage contract](/system/choice-group)

### When to use

- Escalation channel, timeout behaviour, receipt policy: any setting with two to five named options.

### When not to use

- For autonomy. Use AutonomyLadder.
- For a true on/off. Use Toggle.
- For more than about six options. Use a select.

### Props

- **label / value / onChange / options** (required). Options carry label, description, optional icon and tone.
- **layout**: `"list" | "row"`. List is a stack of cards; row is a wrapping line of chips.
- **hint / disabled**.

### Accessibility invariants

- A fieldset with a legend; native radio inputs underneath, so arrow keys and screen readers behave.
- The focus ring is drawn on the visible card or chip via the hidden input's focus-visible state.
- A warning-toned option shows a glyph when unselected and the refused colour when selected. Never colour alone.

### Implementation instructions

Use ChoiceGroup for the escalation and receipts sections of a contract editor. Give every option a one-sentence description in list layout. Mark options that go ahead without the user as tone="warning". Do not use it for autonomy or hard rules.

### States

- List
- List with a warning option
- Row (chips)
- With icons
- Disabled

---

## Toggle

A switch. Ink when on, never the accent.

Source: `design-system/primitives/Toggle.tsx`

```tsx
import { Toggle } from "@/design-system/primitives/Toggle";
```

[Live states and usage contract](/system/toggle)

### When to use

- Binary settings such as what-if mode or dark mode.

### When not to use

- For autonomy. Use AutonomyLadder.
- For hard rules. Use HardRule.

### Props

- **checked / onChange / label / description / size / labelSide**.

### Accessibility invariants

- role=switch with aria-checked; the label is a real label element.

### Implementation instructions

Use only for true on/off settings that are safe to flip casually.

### States

- Off
- On
- With description
- Small
- Disabled
