---
type: plan
title: Standards review 2 — what easel found between v0.32 and v0.45, as rules for every caddie app
slug: caddie-standards-review-2
status: proposal
owner: rian
created: 2026-09-02
related:
  - caddie-ui-standard-proposal.md
  - /srv/apps/caddie/.logs/planning/01-vision-and-roadmap.md
  - /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: >
  Second input for the caddie session. Pass 1 (caddie-ui-standard-proposal.md,
  v0.32) established the shared pack and the two-row shell. Since then easel
  went through fourteen versions of real use by rian: the walkthrough moved
  into the panel, the board became the project's journey, discussion became
  one mode with every subject as an item, notes grew areas and moved after
  posting, the tool tour became a walkthrough that starts on the project page,
  attachments landed, and a dozen shell details broke and were fixed. This
  document turns all of that into rules for ALL caddie apps, says where each
  piece lives now and where it should live, and lists the concrete doc
  amendments and open questions for the framework.
---

# Standards review 2 — for every caddie app

## 0. How to read this

Easel is one drop-in in an ecosystem. Nothing below is an easel feature; each
item is a **rule** with the **failure** that taught it, so the next app inherits
the rule without the failure. Tags say where a thing lives:

- **[pack]** — in `caddie-ui` today (canonical copy in easel until caddie
  stamps it)
- **[app → kit]** — built in easel's backend/frontend in the standard's shape,
  ready to lift into `bw_interaction` / the react-admin pack / the tool contract
- **[caddie]** — belongs to the hub (the project page, the stage model, the
  status contract)
- **[rule]** — a sentence for a standards doc; the doc and section are named
  in §8

Pass 1's rules still stand; they are not repeated except where refined.

---

## 1. The shell

**1.1 The panel pins to the global row's MEASURED bottom.** [pack] [rule 05 §3]
`GlobalBar` publishes `--cu-shell-bottom` (ResizeObserver on the row and the
body, scroll in any scroller, resize; coalesced on a timer, not rAF). Anything
an app puts above the row — a dev-mode banner, and caddie's host bar will be
one — pushes the row down; a panel pinned to the nominal `--cu-global-h`
started underneath it with its close and back controls hidden. *Failure:* for
a day nobody on the team could find the walkthrough's exit; the client, with
no strip, could. Nobody noticed because the team is the only one who sees the
strip. **Corollary:** anything measured must be re-measured when the layout
moves, not only on scroll/resize.

**1.2 A docked panel pushes the app area; it never covers it.** [pack] (pass 1,
still true) — `cu-panel-push` on the app bar, strips and the work surface.

**1.3 Escape is a contract across a frame boundary.** [rule 04 §6 / 05 §3]
A tool that hosts foreign content in a sandboxed frame forwards **Escape,
only Escape**, from the frame to the app, and the app replays it on its
document so every Escape handler (panel, tour, menu) behaves the same whether
the design or the app has focus. *Failure:* after one click on the design,
Escape did nothing and the person was "stuck" in a walkthrough.

**1.4 The app row holds five things, at most.** [rule 05 §3]
The navigator (where am I, what else is there) · a quiet **state mark** (the
preference star) · the **one primary act** (▶ Walkthrough) · the **one panel
toggle** (Discussion, with its count) · a **Team** menu for the team's tools.
Everything else is not a button. *Failure:* easel's bar had a mode toggle,
Choose, Author, Import, Copy link, Contents and Discussion; rian: "I don't
like comment vs play … I don't think we need any other buttons." Seven
controls became four and nothing was lost — the removed ones had been
scaffolding for the builder, not acts for the reader.

**1.5 One mode, not a toggle.** [rule 04 §6]
The panel being **open IS the mode** (anchors shown, a click on the work drops
one); closed is the work, plain. A separate comment/play switch was a second
control for the same idea.

**1.6 The client parses BOTH error shapes.** [app → kit: react-admin pack]
Routes answer `{detail: {error_code, summary, details}}`; the kit's auth
middleware answers a top-level `{error_code, summary, details}` (401 signed
out, 403 read-only View As). easel's `apiError()` reads both. *Failure:* rian
pasted a screenshot while viewing as the client and got "Upload failed (403)"
— the middleware had sent a perfect sentence ("You are viewing as someone
else, read-only. Return to yourself…") that no BW app's client shows. Every
app scaffolded so far has this gap unless its client was written by hand.

---

## 2. The one panel

**2.1 One panel, content by STATE, not only by tabs.** [pack] [rule 04 §6]
easel ended with **no tabs**: the panel is the Discussion, or the Walkthrough,
or the Tool Introduction, depending on what the person is doing. Tabs remain
in `SidePanel` for apps that need them; the rule is *one frame, one Escape,
one right edge*, whatever is inside.

**2.2 The list stays a list.** [pack: `ThreadCard.expanded` + children,
`ThreadReplies`] [rule 04 §6]
A conversation opens **inline under its card** — the replies, then a
composer — when **Reply** is pressed (the count sits on the button). The card
itself never opens a thread: a card whose subject has a place goes **to it**
(the spot on the canvas, highlighted); a card whose subject has nowhere to go
does nothing but Reply. *Failures:* (a) the first list opened a drill-in on
every click and became a corridor; (b) rian reported "replies don't post" —
they did; the drill-in view did not refresh. Inline removes the whole class
of bug: what you post appears where you typed it.

**2.3 A subject nobody has spoken about is still listed — as a starter.**
[rule 04 §6] "Page › Homepage — nothing yet, write the first comment →";
opening it is an inline composer. So "where do I say this?" has one answer,
and the first word on a subject is as easy as the tenth.

**2.4 `PanelSection` for "many things, one expanded".** [pack] The
walkthrough outline (every design's tour plus the tool introduction, the live
one open) is the first use; settings in groups and an inspector are the next.

**2.5 `Timeline` is points, not numbers; `Stages` is the project rail.** [pack]
(pass 1 + v0.35/0.38.) A numbered rail reads as a ranking; the count is in the
header. The rail's **read-selection is distinct from the active stage**:
stepping back to see what a stage collected must never look like moving the
project.

**2.6 A picture opens in a lightbox, never a new tab.** [pack: `Lightbox`,
`openLightbox`; `Body` uses it] A new tab takes the reader away from the
conversation they were in. One lightbox mounted at the app root; anything can
open it; click, Escape, × close it.

**2.7 A highlight is a box AND a dark, BLURRED surround with the box cut
out.** [app → pack: `Spotlight` (dim + box), `ToolTourHighlight`]
Implemented as a full-surface overlay with `backdrop-filter: blur(3px)` and an
even-odd `clip-path` hole — a `box-shadow: 0 0 0 9999px` can darken the
surround but never blur it, and rian wanted the highlighted area to *stand
out*. The same treatment for a walkthrough beat, an open area note, a draft
box, and the tool introduction's targets; a point with **no target dims the
whole page** so the panel is the focus. **No transition on the box:** it
jumps. A transition lags the words (and a background tab freezes it).

---

## 3. Conversation (the Interaction Standard, §4–5)

**3.1 Anything a person can look at is a subject.** [app → kit] [rule 04 §4]
easel has four: `pin` (a spot **or an area** on the design), `screen` (the
page), `option` (this version), `step` (a walkthrough point). All one shape:
one thread per subject (UNIQUE), created on the first comment, counted in the
rollup the same way, notified with a deep link to the subject in **its own
view** (`?mode=play&step=N`, `?pin=`). *Failure:* 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. **Put the composer where the subject is
shown.**

**3.2 One discussion read per work surface.** [app → kit]
`GET /api/options/{id}/discussion` returns every subject's conversation for
the design in one shot (pins, page, version, points); writes stay on their
own subject routes; every conversation mutation invalidates the one read. The
viewer's Discussion mode lists them together. A kit should offer
`discussion_of(subjects…)` the same way.

**3.3 A conversation can be re-filed under another subject.** [app → kit]
`POST /api/threads/{id}/move {subject_type, subject_id}`: the comments keep
their authors and times and join the target's conversation (created on
demand; `resolved` carried to a fresh target); the source shell is deleted,
and its marker with it if it was a note on the design. The author or the
team; outsider → 404, another client → 403. Moving **onto** a spot is not
offered (it needs a click on the design). Implementation note for the kit:
comments are delete-orphan children of their thread — re-parent through the
relationship, never by deleting.

**3.4 A conversation's label says WHERE it is, in the app's own nouns.**
[rule 04 §6] `Page › Homepage` · `Homepage › Option 1` · `Option 1 › Point 4`
(a note on the design) · `Walkthrough › Point 2`. Never a type word
("Version", "Pin"). rian's exact ask; the breadcrumb is the label.

**3.5 The point in a sequence is the ROOT of its conversation; the comments
are its replies.** [rule 04 §4] A walkthrough point's card shows the point's
own words (author "Walkthrough", the team), and everything said about it as
replies; it is listed only once someone has replied — the points themselves
live in the walkthrough. This is true of any authored item a person reacts to
(a punchlist item, a review issue): the item is the root.

**3.6 Anchored feedback: point or area; draft on press; movable after.**
[app; the interaction model for any spatial tool] [rule 04 §4]
- A note is a **point** (x/y) or an **area** (x/y/w/h, `NULL` = point). Press
  drops a pulsing draft marker at once; press-and-drag draws the area; nothing
  is saved until the words are posted (rian: "so we know where we're placing
  the comment").
- An **open area note is highlighted like a beat**; a closed one keeps a
  dashed outline.
- After posting, the author or the team can **move** a marker (drag) and
  **resize** an area (the same adjuster as a beat's box — drag inside to move,
  a corner to resize). The move route takes the size.
- A click on a marker opens its conversation; a card click goes to the marker.

**3.7 Attachments** — pass 1 §5 plus: **the composer holds the file; the
markdown is appended on Send**; a comment may be a file alone; the reply
count lives on the Reply button. [pack + app → kit: `services/attachments.py`
is punchlist's with `subject_id`/`url_base` parameters — lifts as-is]

**3.8 Notification kinds.** [rule 04 §5] `decision` (pass 1) still proposed.
Add: the **round-closer** ("Feedback complete") emits `turn` to the manager
side AND mail — moment two of two, the first being "presented". A tool must
never rely on someone happening to look at the bell for the message they are
waiting for.

---

**3.8 @mention search (added 2026-09-07)** — review's composer searches
people as you type `@`; rian calls it important, and easel had the server
side (mentions parsed on write) without the way to write one. Now in the
pack: `Composer.mentions?: (q) => Promise<MentionHit[]>`; the list is in
flow under the box (never a popup a scrolling panel or a pinned footer can
clip), arrows/Enter/Tab/Escape, and Escape stops at the list so it never
closes the panel. **The rule for every app:** the search returns exactly
the set the server would notify (easel: the project's audience via
`/api/projects/{id}/people`), so a name in the list is never a mention that
silently drops. The pack never knows the directory.

**3.9 A note is anchored to the design, not the page (added 2026-09-16)** —
a client opened three accordion items while reviewing, the page grew about
850px, and every note she left was stored as a percentage of that taller
page. For everyone else each note sat above the words it was about, and in
her own session the notes slid whenever an accordion opened or closed. **The
rule for every app that pins feedback to foreign content:** store the place
as an element of that content (a selector unique in its document, built when
the note is dropped) plus the point's position inside the element's box, and
measure it back live; keep the coordinates only as the fallback. Re-measure
on every change the content reports, not only when its total size changes.

**3.10 Every client comment reaches the team (added 2026-09-16)** — review's
rules (a mention; a reply in a thread you are in) cover a conversation
already going and say nothing when a client STARTS one, which is most client
feedback: forty notes arrived and no one on the team was told. Each comment
from the client side notifies the team members not already told about it,
with a new kind, `comment` ("commented"), deliberately not a "needs you"
kind. Proposed as an addition to the Interaction Standard's §5 vocabulary.

## 4. The journey (caddie's project model)

**4.1 A tool's landing page is the project's journey, not the tool's
inventory.** [caddie] [rule 01 §4 / 05 §3]
Title · blurb · **the stage rail** (done / here / next, click any to read what
was collected or what is coming) · **the active stage's work** (the tool's) ·
**one primary act** (Start the walkthrough) · **one round-closer** (Feedback
complete). "View" and "choose" are quiet on purpose. The team's tooling folds
under it. easel holds `project_stages` in **caddie's own shape** (position,
title, body_md, status planned/active/done/skipped, client_visible,
started_at/closed_at, key = source_template_key), seeded from a roadmap
template on first read, **until the hub owns it** — then the tool reads
stages from the host and drops the table.

**4.2 "Feedback complete" is a round marker the client owns.** [caddie: 02
status contract] `client_closed_round_at` (+ by whom); the rollup reads it as
the agency's turn unless everything is already decided; **re-presenting
clears it** (a new round). A tool cannot infer "they are done" from silence.

**4.3 Stage prose is the team's, per project; planned stages ship with
template prose.** [caddie: `stage_templates.body_md`] The scope stage carries
the design scope and the client's direction, written FOR the client (no
provenance, no open questions — rian rewrote the first draft for exactly
that). `Prose` renders it.

**4.4 A concept card is a picture, a tag, a line, and quiet acts.** [tool]
Uploaded picture (a live frame at card size is a silhouette), tag, one line on
what the concept represents, `Look at it ↗`, `☆ Prefer this one`, `Comment`
(general, per concept — a subject). The team gets Edit / Add a picture on the
card.

**4.5 The page speaks to the person.** [rule 05 §3 + kit ask]
"Hi Darren — 2 concepts are ready for you." / "Thanks, Darren — we've been
notified." / "Welcome, Darren". easel derives the first name from the
username because `/api/me` carries **no display name** — the kit should.

---

## 5. Guided tours (a new section for 04 or 05)

**5.1 A tool introduction IS a walkthrough.** [app → pack: `Tour` =
`BoardTour` + `ToolTourHighlight` + the stepping logic] [rule]
Points in the side panel on the same rail as a design walkthrough; the part
of the page each point is about lit up with the blurred surround; a point
with **no target dims the whole page** so the panel is the focus. **No
coach-mark bubbles** — a bubble floats over the thing it explains and has to
be kept on screen; the panel is always there.

**5.2 It starts on the project page, hands into the tool, and closes on the
project page.** [caddie ↔ tool contract] Welcome · where we are (the stage
rail, and what this round is for) · the concepts · *Next: the walkthrough* →
the tool's own points (where you are, the walkthrough, Discussion, notes, the
blurred photos) → the design walkthrough, every version in turn → back to the
project page: your preference · when you are done (Feedback complete). The
hand-off is a URL contract: `?tour=1` into the tool, `?intro=1` carried across
versions, `?tour=done` back. When caddie owns the project page, caddie runs
the opening and the close and hands the middle to the tool.

**5.3 Points are data; absent targets skip themselves; the version re-offers.**
[rule] `TOUR_*_STEPS` are sentences with `targets` (first laid-out wins) and a
`{name}` placeholder; a point whose control this person does not have (the
preference mark, for the team) is skipped in either direction;
`TOOL_TOUR_VERSION` bumps when the content changes enough to show again.

**5.4 No popup gate.** [rule] A first visit opens the introduction by itself,
once, with Skip always one press away. A modal that asks "shall I show you
around?" is a wall before the door.

---

## 6. Engineering practices worth standardising (05 §3 / §5)

**6.1 A background tab freezes rAF, smooth scroll and CSS transitions.**
Live checks from the MCP tab (which is hidden) must not trust any measurement
that depends on those having run — measure DOM state, or remove the
animation. *Failures:* a "stale" shell offset (rAF-coalesced), a "stale"
highlight box (a 180ms transition), a "broken" scroll-to-note (smooth
scroll). All three were the tab, not the code — but two of the three led to
code that is better without the animation anyway.

**6.2 The deploy gate is `tsc` + the full backend suite, from a clean tree.**
Two sessions share one working tree (Adi's and mine): commit **by explicit
path**; `git add -A` from either side sweeps the other's files (it happened:
a migration and a service rode in someone else's commit). Bump the version
and add the CHANGELOG line per deploy; `/api/meta` proves the deploy, the
**loaded asset hash** proves the UI.

**6.3 Deep links carry state, and a component that stays mounted across
routes must re-arm URL-driven refs on the id change.** `?step=`, `?panel=`,
`?pin=`, `?mode=`, `?tour=`, `?intro=`, `?feedback=`, `?v=` — every one is a
link someone can paste. *Failure:* the viewer read `?step=` once at mount, so
"Next: The Welcome Desk" landed on the other design with no tour open.

**6.4 A null guard is not optional in a shared component.** *Failure:* 0.39.0
shipped a blank viewer for four minutes — a point lookup ran with no subject
selected. Caught by the live check; the rule is that the live check is part
of the deploy, not an afterthought.

**6.5 Content lives in the tool's own tracker, not in code.** Stage prose,
concept blurbs, walkthrough points, tour points — all data the team edits in
place (or, for tours, sentences in one file). The one-off seed for league is
in a session scratchpad, not the repo, on purpose.

---

## 7. Inventory — where each piece lives now, and where it should

| Piece | Now | Proposed home |
|---|---|---|
| `GlobalBar` `AppBar` `LocatorProvider` `SidePanel` `PanelSection` `Bell` `ThreadCard` `ThreadReplies` `ThreadView` `Composer` `StatusChip` `Body` `Prose` `Lightbox` `Menu` `CountChip` `Timeline` `Stages` | `caddie-ui` (canonical copy in easel) | caddie stamps it (05 §2), byte-identity checked like `bw-admin` |
| `Spotlight` (dim + box), `ToolTourHighlight`, `BoardTour` + the stepping logic, `InlineThread` | easel `views/Viewer.tsx`, `components/` | pack: `Highlight`, `Tour`, `InlineThread` |
| `apiError()` (both error shapes) | easel `lib/easelApi.ts` | react-admin pack / QUICKSTART's generated client |
| subject threads (`screen`, `option`, `step`), the discussion read, `move_thread`, attachments service + two routes, area notes (`w/h`), move/resize | easel backend | `bw_interaction` |
| `project_stages` + `services/stages.py` (roadmap template, status → timestamps), `feedback_completed_at/by` + the rollup rule | easel backend | caddie (stages, `client_closed_round_at` in the status contract) |
| the tour hand-off (`?tour=1` / `?intro=1` / `?tour=done`) | easel board + viewer | tool contract (02) once caddie owns the project page |

---

## 8. Doc amendments, concretely

- **01 (vision):** §4 — *the tool's landing page is the project's journey*;
  stages may be held in a tool in caddie's shape until the hub owns them; the
  introduction's opening and close belong to the project page.
- **02 (tool contract):** §6 host bar renders INTO `GlobalBar`'s locator
  (pass 1); status contract gains `client_closed_round_at` (+ by whom) and the
  rule that presenting again clears it; the tour hand-off URLs; notification
  kind `decision`.
- **04 (interaction):** §4 — subjects are plural and named per app; a
  discussion read per work surface; re-file (`move`) with the delete-orphan
  note; anchors are points or areas, drafted on press, movable after; the
  item in a sequence is the root of its conversation. §5 — addendum wording
  ("the composer holds the file"); the round-closer emits `turn` + mail. §6 —
  name the pack pieces; one panel, content by state; the list stays a list
  (Reply opens inline; the card goes to the subject); starters; labels are
  breadcrumbs; lightbox; the highlight is dim + blur + cut-out; Escape across
  frames; one mode, not a toggle.
- **05 (runbook):** §2 stamp the pack; §3 the shell section (two rows, the
  five things an app row may hold, the measured bottom, panel push), the
  error-shape rule, the deep-link rule; §5 the background-tab rule and the
  two-sessions-one-tree rule. Also: *no popup gate; the introduction is a
  walkthrough.*
- **UI-STANDARD.md (react-admin):** the client reads both error shapes;
  `/api/me` should carry a display name.

---

## 9. Open questions for caddie

1. **Stages hand-over.** When the hub owns `stages`, does the tool keep a
   read-only mirror (for the rail) or read the host live? Which side seeds a
   new project's roadmap?
2. **The introduction's opening and close** are about the project page,
   which will be caddie's. Proposal: caddie runs "Welcome / where we are /
   next: the tool", hands off with `?tour=1`, and receives `?tour=done` for
   "your preference / when you are done" — a tool never draws the project
   page's points.
3. **Board-level conversations** (the page thread) — when the project page is
   caddie's, is the page a subject of the hub or of the tool?
4. **Display name** in `/api/me` — one field, every app speaks to the person.
5. **Pack tooling:** stamping + a byte-identity check (like `--check` for the
   account kit) so "vendored, never hand-edited" is verified, not hoped.
6. **`decision`** as a notification kind — still open from pass 1.
