---
type: plan
title: Proposal to caddie — a shared UI pack, and what easel found building it
slug: caddie-ui-standard-proposal
status: proposal
owner: rian
created: 2026-09-02
related:
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
  - /srv/apps/caddie/.logs/planning/05-building-a-caddie-app.md
  - /srv/system/id-auth/app-auth/react-admin/UI-STANDARD.md
summary: >
  Input for the caddie session, from easel's caddie-alignment pass
  (rian, 2026-09-02). Three apps had each hand-rolled the bell and the
  conversation surface the Interaction Standard says are shared. easel now
  carries a vendorable `caddie-ui` pack that implements the standard's chrome
  once; this proposes it as the canonical pack, names the standard additions
  it implies (a two-row shell with a global row, the side panel as the one
  wrapper), and lists what the framework should change.
---

# Proposal to caddie: the `caddie-ui` pack

> **Pass 2 (2026-09-02, v0.32 → v0.45) is in `caddie-standards-review-2.md`** —
> the shell, panel, conversation, journey and tour rules learned since this
> document, the inventory of where each piece lives, and the concrete doc
> amendments. Read that one first; this one is the origin of the pack.

## 1. The finding

The Interaction Standard (04 §6) says the bell is "a vendored component every
app mounts" and the conversation surface is "a side drawer modelled on
review's notes-sheet." Neither existed as code. So caddie (`cd-bell`,
`StageComments`), punchlist (`pl-bell`, `CommentsDrawer`) and easel (`bell`,
`Drawer` + floating balloons) each built their own — three bells, three
comment surfaces, three CSS vocabularies, all slightly different. That is
exactly the drift the standard was written to prevent, and it happened in
three weeks with three apps. It will not get better with the fourth.

The bw-admin pack solved this for identity: one canonical folder, vendored
byte-for-byte, `--bw-*` tokens with fallbacks, a written UI standard. The
same mechanism is what the interaction/shell layer needs.

## 2. What easel built (proposed as canonical)

`main/frontend/src/caddie-ui/` — React-only, app-agnostic, themed by `--cu-*`
tokens that fall back to the app's plain tokens then to neutrals (the bw-admin
mechanism, so one theming step themes both packs). README in the folder is
the standard text.

| Piece | Standard it implements | Proposed standard text |
|---|---|---|
| `GlobalBar` | 02 §6 host bar, extended | **The two-row shell.** Row 1 is *global*: brand, locator, bell, account menu — identical in every app. When caddie launches the app, the host bar's "Part of {project} on Caddie · ← Back" IS the locator content. So the slot the M2 drop-in needs already exists in every app, and a person always knows where they are. |
| `AppBar` | new | Row 2 is the *app's*: its own functions for the thing on screen. Three slots (left · centre for a mode control · right). Sticky under the global row. |
| `LocatorProvider`/`useLocator` | new | Views declare their path; the global row renders it. In caddie context the hub sets it. |
| `SidePanel` | 04 §6 "side drawer" | **The one side panel** — Gutenberg's inspector idea: one frame (header, tabs, scrolling body, pinned footer), many contents. Discussion is one tab; contents, settings, whatever the app needs are others. Docked, no scrim by default; Escape closes. An app never builds a second drawer. |
| `Bell` | 04 §6 | Button: 34px ring, count badge, needs-you accent. Panel: review's format — sentence rows ("erin *replied in a thread*"), "on **context**", category chip with dot, one-line preview, time, unread tint + dot, show-resolved, mark-all-read. Presentational: the app polls (transport differs; looks must not). |
| `ThreadCard` / `ThreadView` / `Composer` | 04 §4 + review's notes-sheet | Status chip · author · time · resolve/reopen right-aligned; body; "N replies / Reply"; replies in a left-ruled column; composer pinned in the panel footer with ⌘/Ctrl+Enter. |
| `StatusChip` | review's status-dot idiom | Colour is a token expression the caller passes — the one sanctioned inline value (it is data). |
| `Menu` / `CountChip` | new | **A grouped dropdown switcher** for "which thing am I looking at" when the things have structure (pages → directions, boards → items). The trigger is a real control with a count chip — alternatives are visible without words. easel's first switcher was two chevrons around a name, and a client could not tell a second concept existed; a label-shaped control gets read as a label. |
| `Timeline` | new | **Where you are in a sequence.** A vertical rail of *points* — behind (filled) / here (ringed, expanded in place) / ahead (hollow) — and every point jumps. Points, not numbers: a numbered rail read as a ranking or a form to fill in order, and the count already sits in the header ("Step 2 of 5"); `numbered` stays for ordinals that mean something. A guided walkthrough is one panel content built on it: the beat's words, approval and conversation sit inside the rail, navigation in the pinned footer, the spotlight on the work beside it. Any tool with a sequence (onboarding, a punchlist flow, a signoff) reads the same way. |
| `Stages` | 01 §4 timeline | **Where the project is.** Caddie's client timeline ("done stages compact with checkmarks, the active stage expanded, upcoming stages muted") as a horizontal rail with a read-selection distinct from the active stage. easel renders it from a `project_stages` table that mirrors caddie's `stages` shape (position, title, body_md, status planned/active/done/skipped, client_visible, started_at/closed_at, key = source_template_key) — **held in the tool until the hub owns it**, then read from the host. The tool's own work (easel's concepts, walkthrough, feedback round) renders INSIDE the active stage — which is what "a tool attached to a stage" looks like from the client's side. |
| `Prose` | new | **Long-form text** — block-level markdown-lite on `Body`. A stage's body, a brief, what a client said. One renderer, never innerHTML. |
| `PanelSection` | new | **A collapsible group in the panel** — Gutenberg's PanelBody. Header row that opens and closes, several stacked, usually one open; a meta slot says what's inside without opening it; `current` marks where the person is. The one shape for "many things, one expanded": easel's tour outline (every design's walkthrough plus the tool tour, the live one open) is the first use; settings in groups and an inspector with categories are the next. |

### Two shell rules learned by building the walkthrough into the panel

- **The panel starts below the global row, never over it.** The first cut
  covered the bell and the account menu whenever a panel was open. The
  global row is the one thing that must always be reachable. *Refined
  (v0.36.3):* below the row's **measured** bottom (`GlobalBar` publishes
  `--cu-shell-bottom`), not its nominal height — a strip above the row (a
  dev-mode banner; caddie's host bar will be one) pushed the row down and
  the panel's head, close and back controls sat hidden under it. Nobody on
  the team could find the walkthrough's exit; the client, with no strip,
  could.
- **A key pressed inside a sandboxed frame never reaches the app.** The
  mockup bridge forwards Escape (only Escape) and the app replays it on its
  document, so the panel, a tour and a menu close the same way whether the
  design or the app has focus. Any tool that hosts foreign content in a
  frame needs the same one-key forward.
- **A docked panel pushes the app area aside; it does not cover it.** The
  app bar, any strips, and the work surface take `cu-panel-push` while a
  panel is open and reflow beside it — an inspector beside an editor. A
  spotlight or a pin on the right edge of a design was otherwise hidden under
  the panel, which for a walkthrough is the whole point lost. Phones keep the
  full-width overlay.

### A subject rule learned by putting comments on walkthrough points

- **Anything a person can be looking at is a subject.** The Interaction
  Standard's `subject_type` seam already allowed it; easel now has threads on
  pins (a spot), screens (a page) and walkthrough beats (a point in a tour),
  all the same shape — one thread per subject, created on the first comment,
  counted in the rollup the same way, notification deep-linking back to the
  subject in its own view (`?mode=play&step=N`). The first cut had the client
  *pause the tour and drop a pin near the spot* to ask about a beat — a
  spatial note for a question about the words. The rule: **put the composer
  where the subject is shown**; never make a person leave the thing to talk
  about it.

### The journey rule learned by redesigning easel's board

- **A tool's landing page is the project's journey, not the tool's
  inventory.** easel's board listed screens and options — the tool's
  furniture. rian's redesign puts the roadmap first (where we are, what was
  collected, what is coming), then the tool's work inside the active stage
  with ONE big act (start the walkthrough) and ONE way to close the round
  (Feedback complete → the team is told). "View" and "choose" are quiet on
  purpose. When caddie's project page exists this is the same page seen from
  the hub; until then every tool should draw it the same way from the same
  stage shape.
- **"Feedback complete" is a round marker the client owns.** A tool cannot
  infer "they are done" from silence; the client says so, the rollup reads
  it as the agency's turn, and re-presenting opens the next round. Proposed
  for 02's status contract: `client_closed_round_at`.

### A popup rule learned by putting a team menu at the right of the bar

- **A dropdown decides which edge it hangs from; the caller should not have
  to.** `Menu`'s popup was `left: 0` always, so a trigger sitting at the
  right of the app bar opened rightwards, off the window, and was half
  hidden. Asking every caller to pass an alignment gets it wrong the moment
  the window narrows or the trigger moves. The popup now measures itself
  against its trigger when it opens, flips to right-aligned when it would
  overflow, and re-checks on resize; `align` remains for the rare caller who
  must decide. The general rule for the pack: anything that positions itself
  relative to the viewport owns staying inside it.

## 3. What this asks caddie to change

1. **Adopt the pack as canonical and stamp it.** Move the folder to caddie's
   workspace (or next to `react-admin/` in the app-auth kit, which already
   has the vendoring tooling), and have `05 §2`'s create runbook copy it in.
   Until then easel holds the canonical copy and this doc is the pointer.
2. **Amend 04 §6** to name the pieces: "the bell" → `caddie-ui/Bell`; "the
   side drawer" → `caddie-ui/SidePanel` + `Thread*`. Add: *an app mounts the
   pack's components and never re-implements them; a needed change goes to
   the pack.*
3. **Amend 02 §6** so `caddieHost.ts`, when it lands, renders INTO
   `GlobalBar`'s locator slot rather than mounting its own bar. Two bars at
   the top of a launched tool (host + app) was the alternative, and it costs
   40px on every screen for a line of text the global row can carry.
4. **Add a shell section to 05 §3** (stack requirements): "the two-row shell
   from caddie-ui; app functions in the app row, never in the global row."
5. **Caddie's own Bell and StageComments, and punchlist's, become pack
   consumers.** Each is currently ~100–140 lines that the pack replaces with
   a ~30-line adapter (easel's `components/Bell.tsx` is the shape).
6. **Notification kinds need one more.** The standard's vocabulary
   (`mention · reply · turn · status · resolved`) has no word for "something
   was decided" (a direction picked, a beat approved). easel emits these as
   `turn` and `status`, which reads wrong in the bell ("needs you" for a
   decision that closed a loop). Proposed: `decision`.

## 4. What easel had to decide that the standard did not settle

Recorded so the next app does not re-decide them:

- **Where the account chrome lives in a full-screen tool.** Adi had moved the
  bell + account menu INTO the viewer toolbar to save one bar's height; rian
  ruled the global row stays. The cost is 40px; the win is that every caddie
  app has the same top edge and a place for the host bar.
- **Anchored feedback vs. the side drawer.** easel's pins are InVision's
  idiom; the standard's is review's drawer. They compose: pins stay on the
  design as numbered *anchors*; the *conversation* lives in the panel. A
  click on the design drops a marker and opens the panel's composer; a click
  on a pin opens its thread. No floating balloons. This is the right general
  rule for any tool with spatial subjects (a page, a canvas, a map).
  *Refined (v0.39.0):* **one Discussion mode, not a comment/play toggle.**
  The panel being open IS comment mode (anchors shown, a click drops one);
  closed is the work, plain. And the panel lists EVERY subject the design
  has — the page, this version, each walkthrough point, the anchored notes —
  as items with starters for the ones nobody has spoken about, so "where do
  I say this?" has one answer. A mode switch that only hid the markers was a
  second control for the same idea.
- **Tabs, not stacked panels.** Contents and settings were separate overlays
  in easel; they are tabs of the one panel now. One frame, one Escape.

## 5. Not done, deliberately

- **Board-level (screen) conversations** in easel still use their own inline
  form (`ScreenNotes`). Next pass: a panel tab on the board, using the pack's
  `ThreadView`.
- **Punchlist and caddie are not migrated.** That is caddie's call once the
  pack is canonical; easel is the reference consumer.
- ~~**Attachments in the composer**~~ — done (easel v0.36.0–0.36.2): the pack's
  `Composer` takes `onAttach(file) → Promise<markdown>` (paste, drop, clip;
  pending thumbnail strip) and `Body` is the one renderer with the addendum's
  own-URLs-only image rule. One amendment to the addendum's wording: *"the
  composer draft receives markdown"* should read *"the composer holds the
  file; the markdown is appended on Send"* — the markdown in the text box
  confused the first person who pasted (rian), and the wire format is
  unchanged either way. The app owns the two endpoints. This is the
  addendum's "composer paste handler + render rule" half of the interaction
  kit, in the UI pack where it belongs; the endpoints + service are the
  backend half, ready to be lifted into `bw_interaction` (easel's
  `services/attachments.py` is punchlist's with the subject generalised).
