# caddie-ui — the shared chrome for caddie apps

The front-end companion to the Tool Contract (`02-tool-contract.md`) and the
Interaction Standard (`04-interaction-standard.md`) in caddie's planning dir.
Those say *what* every caddie app shares — a launch/host bar, one bell, a
side-drawer conversation modelled on review. This pack is *how*: the
components, so it is built once and looks the same in every app.

**Vendoring rule (same as `bw-admin/`):** a full independent copy per app,
refreshed byte-for-byte from the canonical copy. Never hand-edit inside an
app — a change that is right for one app belongs in the canonical pack, or it
is drift.

**The canonical copy is `/srv/apps/caddie/packs/caddie-ui/`.** Stamp it into
an app (and verify it) with:

```bash
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/<app>           # copy in / refresh
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/<app> --check   # byte-identity
```

`--check` exits non-zero on any drift and names the file, so "vendored, never
hand-edited" is verified rather than hoped. `PACK_VERSION` in `index.ts` says
which pack an app carries.

| Piece | Implements | Notes |
|---|---|---|
| `GlobalBar` | the platform row: brand · locator · bell · account menu | 40px, sticky. In a caddie launch the host bar's "Part of {project} on Caddie · ← Back" is the locator content — so the row and slot exist in every app before the drop-in does. |
| `AppBar` | the app's own row under it: left · centre (mode) · right | sticky under the global bar; wraps at phone width. |
| `LocatorProvider` / `useLocator` | "where am I" | views declare their path; the global bar renders it. |
| `SidePanel` | the one side panel — Gutenberg-style: header, tabs, scrolling body, pinned footer | docked, no scrim by default; `modal` adds one. Escape closes. |
| `Bell` | Interaction Standard §6 | button = 34px ring with count; panel = review's format (sentence rows, "on context", category chip, preview, time, show-resolved). Kinds: `mention · reply · turn · status · resolved · decision` (04 §5); only mention and turn are "needs you". Presentational: the app polls. |
| `ThreadCard` / `ThreadView` / `Composer` | Interaction Standard §4 surface, review's notes-sheet | status chip with dot, author, time, resolve/reopen right-aligned; replies left-ruled; composer pinned in the panel footer. |
| — `ThreadCard.onSelect` | the card is the way TO the subject | Clicking the card goes to what the thread is about (the spot on the canvas) and highlights it; a subject with nowhere to go does nothing on click. Reading is the common act — a card that drilled in on every click made the list a corridor. |
| `ThreadStarter` | a subject nobody has spoken about | listed beside the threads ("Page › Homepage — nothing yet, write the first comment →"); opening it is an inline composer passed as children. Every subject the surface has is listed, spoken about or not, so "where do I say this?" has one answer. |
| — `ThreadCard.expanded` + children, `ThreadReplies` | the list stays a list | The conversation opens INLINE under the card — `ThreadReplies` (the ruled column) and a `Composer` as children — when **Reply** is pressed; the count sits on the button. Nobody is taken anywhere to reply. |
| `Lightbox` / `openLightbox` | a picture, full size, without leaving the page | mount one at the app root; `Body`'s attachment images open it (a new tab took the reader away). Click, Escape, × close it. |
| `StatusChip` | category/status pill | colour is a token expression passed by the caller — never a literal. |
| `Menu` / `CountChip` | a grouped dropdown switcher | for "which thing am I looking at" when the things have structure (pages → directions). The trigger is a real control with a count chip, so alternatives are visible without words; groups carry a right-aligned note; items carry meta + badges; the current one is checked. |
| `Timeline` | where you are in a sequence | a vertical rail of **points** — behind (filled) / here (ringed, expanded in place) / ahead (hollow); every point jumps. Points, not numbers: a numbered rail reads as a ranking or a form to fill in order, and the count already sits in the header. `numbered` stays for sequences whose ordinals mean something. A guided walkthrough is one panel content built on it. |
| `Stages` | where the project is | caddie's client timeline as a horizontal rail: done (filled, ✓) / active (ringed) / planned (hollow) / skipped (dashed); a `meta` word under each title ("Completed 12 Aug", "Up next"); the SELECTED stage (the one being read) is marked separately from the active one, because stepping back to see what a stage collected must never look like moving the project. |
| `Prose` | long-form text | block-level markdown-lite on top of `Body`'s inline rules: `##`/`###` headings, paragraphs, `-` bullets, `>` quotes, `---`. Dependency-free, never innerHTML; the same own-URLs-only image rule. A stage's body, a brief, a summary of what a client said. |
| `PanelSection` | a collapsible group in the panel | Gutenberg's PanelBody: a header row that opens and closes, several stacked, usually one open; the meta slot says what's inside without opening it; `current` marks the section the person is in. The one shape for "many things, one expanded" — a tour of several sections, settings in groups, an inspector. |

## Rules that ride along

- **One panel, content by state.** Discussion, the walkthrough, the tool
  introduction are contents of the ONE `SidePanel`, switched by what the
  person is doing; tabs exist for apps that need them. One frame, one Escape.
- **The panel being open IS the mode.** An app with anchored feedback shows
  its anchors and takes a click as "drop one" while the panel is open; closed
  is the work, plain. No separate comment/play toggle.
- **A subject nobody has spoken about is listed as a starter** ("nothing
  yet — write the first comment →") whose click is an inline composer.
- **Labels are breadcrumbs in the app's own nouns** (`Page › Homepage`,
  `Homepage › Option 1`, `Option 1 › Point 4`), never a type word.
- **Escape crosses a frame boundary.** An app hosting foreign content in a
  sandboxed frame forwards Escape (only Escape) and replays it on its
  document, so the panel, a tour and a menu close the same way either side.

- **The panel starts below the global row's MEASURED bottom** (`GlobalBar` publishes `--cu-shell-bottom`; the panel pins to it). An app strip above the row — a dev-mode banner, a host bar — pushes the row down; pinned to the nominal `--cu-global-h`, the panel started underneath it with its close and back controls hidden.

- **The panel sits below the global row and pushes the app area aside.**
  `SidePanel` starts under the 40px global row (the bell and account menu
  stay reachable); the app puts `cu-panel-push` on its app bar and work
  surface while a panel is open so content reflows beside it, the way an
  inspector sits beside an editor. Phones keep the full-width overlay.

- **The UI consumes capability booleans, never level names** (UI-STANDARD.md).
  Nothing in this pack branches on a role.
- **No inline styles.** The one exception is the status chip's tone, which is
  data (a token expression), per frontend.md.
- **Tokens fall back**: `--cu-*` → the app's plain tokens (`--surface`,
  `--text`, `--border`, `--brand-accent`, `--r-control`, `--r-pill`, `--pin-open`,
  `--pin-resolved`) → neutrals. Theme once in the app root.
- **Token roots**: every outermost class is listed at the top of
  `caddie-ui.css`; add yours there or its tokens silently fail to resolve.
- **The pack imports only React and itself.** App data comes in through props.

## Attachments in the composer (the Interaction Standard addendum, 2026-08-22)

Paste, drop, or the paperclip — all three upload **immediately** through the
app's `onAttach(file) → Promise<markdown>`. **The file never appears in the text
box**: the markdown (`![name](/api/<app-subject>/attachments/<uuid>.<ext>)`) is
the wire format, not something a person should read while writing. Uploaded
files wait in a strip under the textarea (thumbnail or a PDF tile, each
removable) and are appended after the words on Send — **Send posts a normal
comment**, words first, then the files. A comment may be a file alone. There is
no attachment object in the conversation model — the body carries the file. The upload endpoint belongs to the app and follows the addendum's
hostile-surface rules (extension allow-list with no script-capable types,
magic-byte agreement, server-generated filenames, size and per-subject caps,
`nosniff`, visibility re-checked through the owning subject on every read).

**The render rule.** `Body` / `renderBody` is the one comment-body renderer:
markdown-lite (attachment images, links, bold, inline code), dependency-free,
never `innerHTML`. An image renders **only** when its URL starts with the app's
own `imagePrefix` (default `/api/`, narrow it to your attachment route) — an
external image URL in user text is shown as text, never fetched, so a comment
can never make a reader's browser hit a tracking pixel. Links accept `https://`
and the same own-relative shape (PDF attachments are links).

## Changelog (newest first)

- **1.1.0** (2026-09-02, canonical copy moved to caddie) — `ThreadStarter`
  as a pack piece (it was a rule with no component, so the first consumer
  hand-rolled it); the bell speaks `decision`; `PACK_VERSION` exported;
  `.cu-starter` added to the token roots; `domId` on `ThreadRow`/`ThreadReply`
  plus the `is-settling`/`is-target` wash, so a deep link lands on the exact
  comment and settles rather than flashes.
- **1.0.0** (2026-09-02, easel v0.45) — the pack as proposed in
  caddie's `.logs/planning/` from easel's alignment pass: two-row shell,
  the one side panel, the bell, threads, composer with attachments, lightbox,
  prose, menu, timeline, stages, panel sections.
