# easel — handoff

> Append-only, newest-first. One entry per session that meaningfully advances
> or changes the project. Cold sessions orient from here + `brief.md` alone.
> Format per entry: `## YYYY-MM-DD — <one-line summary>` then a short body.


## 2026-09-17 — Comment reactions (v0.62.0)

- Slack-style emoji reactions on every comment: `comment_reactions` table
  (migration a2c4e6f8b0d2, applied live), `POST /api/comments/{id}/reactions`
  toggles, fixed set of six emoji, no notifications. `CommentOut.reactions`.
- caddie-ui pack: new `Reactions`, `onReact` on ThreadCard/ThreadReplies (README
  row). The easel copy of the pack already differed from
  `/srv/apps/caddie/packs/caddie-ui/` before this change (Bell, Menu, Thread,
  css, index, README); the canonical copy was NOT updated. Worth a sync pass.
- Wired in: DiscussionPanel (roots and replies), InlineThread (concept cards,
  walkthrough points), board page notes (ScreenNotes), tour demo.
- Verified live on equinox Illustrated as rian: picker opens, 👍 1 chip
  highlighted, pressed again removed it; table back to 0 rows. 281 tests pass.

## 2026-09-16 (rian's session) Notes anchored to the design; client comments reach the team (v0.61.0)

rian: Alicia (equinox) left comments and asked "do you find that the dots move
around after you've placed them?"; the dots were all out of whack for him too,
and her comments were hard to find: he expected them in his unreads.

**What was there.** 39 notes by alicia on Illustrated (option 9), 19:42 to
21:13 UTC, all saved, nothing failed. None notified anyone: review's rules
(mention; reply in a thread you are in) say nothing when a client STARTS a
thread. The board card said "1 comment" (the general thread only).

**Why the dots moved: diagnosed, not guessed.** Horizontal was right; vertical
was off by up to 3.45 points, and the error had a shape (above its word near
the top, exact at the footer, below in the middle) that no uniform stretch or
zoom produces. Ruled out with fresh loads: zoom (same height and section
positions at 80% and 100%, within 0.03%) and the variant switch. Cause: a
note stored its place as a percentage of the whole page, and she had opened
accordion items (services and FAQ) while reviewing, so the page was up to
about 1,000px taller and each note was stored against that. Proven by
rendering the bundle headlessly in all 256 accordion states and matching the
notes whose comments name a word. Three traps met on the way, worth knowing
for any later replay of a design: (1) this design has a CSS scroll-driven
header (`animation-timeline: scroll(root)`) whose timeline lags a frame in
headless, so a probe that scrolls mixes condensed and full headers (easel's
frame never scrolls, so the header is always full); (2) `.imgwrap` is
`clamp(260px, 40vh, 480px)`, 480px in the page-tall frame but 400px in a
1000px window; (3) a window taller than the page stretches part of the design
to fill it. The faithful replay: 1440x1000 window, scrolling for
elementFromPoint, and a style turning animations off and pinning `.imgwrap` to
480px; it loads at exactly the live frame's 11,540px. Her accordion state
changed during the session, so it was fitted per stretch of time (six
stretches, mean miss 0.05 to 0.18 points).

**The fix, for every note from now on.**
- `pins.anchor` JSON (migration `f8b0c2d4e6a8`): `{s, fx, oy}` = a selector
  unique in the mockup, the point's place across the element (fraction) and
  its distance down from the element's top in CSS px. Pixels down, not a
  fraction: a fraction of a section that contains an accordion slid 1.7
  points when the accordion closed. Areas add `s2/fx2/oy2`. `PinAnchor`
  schema; create sets it; move replaces it, and a move without one clears
  it (the old anchor names the spot it moved away from).
- Bridge (`mockups.py`): `anchor` message (elementFromPoint, walk up out of
  inline boxes, `uniquePath` builds the shortest unique selector, up to 16
  levels), `measure-points` (one round trip for all notes; a hidden element,
  e.g. inside a closed answer, gives its nearest visible ancestor's middle).
- Viewer: the draft names its place 250ms after the pointer settles; drags
  and area adjusts re-anchor on commit; `pinView`/`areaView` draw from the
  live measurement, else the stored percentages through the zoom. Every size
  report from the frame bumps `layoutTick`, and both the notes and the
  walkthrough's selector highlights re-measure on it: closing an accordion
  does not change the frame's height (a document is never reported shorter
  than its viewport), so frameH alone would not trigger it.

**Alicia's 39 notes were re-anchored** from the per-stretch replay (anchors +
corrected fallback percentages), after checking the live frame measures every
one of them exactly where the replay does (0.00 deltas through the zoom).
Named notes now land within 0.27 points of their words, most within 0.1;
before, up to 3.45. Verified by eye in the viewer: the "Has" heading, the
"Is" button, "Are"/"Than", and the before-and-after table's logo, ring,
"From" and "enough". Old coordinates:
`data/backups/pin-anchors-equinox-option9-2026-09-16.json`.

**Client comments reach the team.** `add_comment`: when the author is on the
client side, every manager-side member plus the owner not already told (as a
mention or participant, same dedupe key per comment and person) gets kind
`comment` ("alicia commented on Equinox - website concepts · Illustrated").
Not a "needs you" kind. Pack `Bell` sentence + README row; digest sentence.
Backfilled: 44 each for rian and adi (39 alicia, 5 michelle), dated when
written; the digest mails them after the quiet hour. Three 0.58 digest tests
assumed a client mention reached only the mentioned person; updated.

**Board card:** "N notes on the design" (open pin threads) linking to
`?panel=discussion`.

**Standards:** review-2 §3.9 (anchor notes to the design, re-measure on every
reported change) and §3.10 (every client comment reaches the team, kind
`comment`, proposed for the Interaction Standard's vocabulary).

**Not done / open:**
- Not exercised live: a note following its words when an accordion is opened
  in the viewer. The sandboxed frame's content is unreachable to the browser
  automation. The path is covered (size report, re-measure), but rian or
  Adi should open an accordion above a note once and watch it move with the
  text.
- Found, not fixed: the bridge cancels the default action of EVERY demo
  click (`if (demoClick) ev.preventDefault()`), which is right for `href="#"`
  links but means a walkthrough cannot demo an accordion (a summary click is
  a default action). Scope it to links when needed.
- The frame height ratchet (never reported shorter) leaves blank space at
  the bottom after an accordion closes. Cosmetic now that notes are anchored.
- Uncommitted work from another session remains in the tree (the account
  kit re-vendor, deployed as 0.60.1: `bw_admin_api.py`, `bw_auth.py`,
  `bw-admin/*`, `conftest.py`, two tests, `CredentialPanel.tsx`). Its
  CHANGELOG entry and version bump were committed with this change because
  those two files are shared; its code files were left for their owner.
- 277 tests.

## 2026-09-08 (rian's session) Zoom, and the anchor map that had to come with it (v0.60.0)

rian: design hidden in the margins is invisible because we present at a
fixed width. He asked for a zoom defaulting to 80% that reveals the
margins, with 100% a click away for the real font sizes.

**How it works.** The frame lays out at `baseW / zoom` and the stage scales
it to fit, so 80% gives the page a quarter more room and shows it smaller:
the ratio of the two scales is exactly the zoom. The control sits beside
the variations switch in the bar's centre slot (`ZOOMS = [0.8, 1]`), and
the choice is a per-reader preference in `localStorage` under `easel:zoom`,
not in the URL: it is how someone likes to look, not part of a link.

**The part that needed the care.** Every stored anchor (a marker, an area,
a hand-placed highlight) is a percentage of the design at ITS OWN width.
Laying the page out wider centres it and leaves slack down both sides, so
without a map every note would have slid off the thing it was left on.
`zoomX(x) = x·zoom + 50(1 − zoom)` and its inverse now sit at the boundary:
applied on render (pins, area notes, drafts, hand-placed beat rects) and
un-applied on input (`stagePoint`, pin drags, the adjuster's commits).
Selector-targeted highlights are measured live in the frame, so they are
already in view space and must NOT be mapped; they now re-measure when the
frame width changes (`frameW` added to the measure effect's deps).
`viewRect` snaps an edge within 2% to the frame edge, because a box dragged
to the edge of a full-width band means the band; the tolerance separates
the real edge cases (0.12, 0.89, 98.38, 99.68) from real content edges
(3.65, 94.44, 96.91) in the two live projects.

**The base width is now held, not rediscovered.** A width report is only
acted on when the document came out wider than the frame we gave it (a
design with its own min-width); a report equal to the frame is the frame
measuring itself, and reading that back while zoomed would have shrunk the
base on every tick.

**Verified live, measuring against the real elements through the bridge:**
- league's hand-placed phone highlight sits on `.nheader .phone` at both
  zooms, carrying the same 0.1% authoring offset (83.17 vs 83.07 at 100%,
  76.53 vs 76.45 at 80%).
- A note dropped on `.chB` at 80% stored x=74.03; the element's centre at
  100% is 74.13 and the marker renders at 74.03. Round trip exact. Test
  note deleted; equinox is clean.
- Equinox's Circles variant: the brand ring bleeds to 144% of the frame, so
  it is clipped at 100% and visible at 80%. That is rian's case exactly.
- frameH is identical at both zooms on both projects, which is why y needs
  no map: a centred fixed-width design does not reflow. A fluid design
  would, and nothing can fix that; worth knowing if one ever lands here.

## 2026-09-07 (rian's session) League cleared for launch; the people page linked (v0.59.2)

rian is sending darren the email now. Cleared on league with a one-off in
the app container: 12 threads (9 step, 1 screen, 2 pin), 15 comments (rian
and test, 8 already soft-deleted), 2 pins, 7 notifications, 2 held outbox
rows, test's visit row; feedback state and preference were already unset.
Walkthrough points, stages, blurbs, thumbnails, tour overrides untouched.
State back to `waiting_client`; rian's bell at 0. Dev Mode was ON at the
time, so nothing mails until he flips it.

rian could not find how to manage who is on a project (equinox is next,
instance `equinox-website-concepts`, currently adi only). The kit's roster
page exists at `/p/<id>/members` ("Who can see this project") and nothing
linked it; Team tools on the board now does. Admin → People still shows the
app-wide roster with per-project grants.

Noticed: `test` is no longer a member of league (someone removed it after
the invite); the account itself still exists centrally.

## 2026-09-07 (rian's session) @ searches people; the Discussion button nudges (v0.59.0)

rian: review lets you type @ and search users, that is important; and make
the Discussion button glow or bounce now and then while the panel is
closed, with a comment icon.

- **Pack (easel's copy is canonical, review-2 §7):** `Composer.mentions?:
  (q) => Promise<MentionHit[]>`; `MentionHit` exported; `.cu-mentions` list
  in flow under the box (never a popup a scrolling panel or pinned footer
  clips); arrows, Enter/Tab insert `@username `, Escape closes the list and
  stops there (`stopImmediatePropagation`, so the panel's document
  listener never sees it); results debounced 120ms with a sequence guard;
  README row added; review-2 got §3.8 with the rule for every app: the
  search returns exactly the set the server would notify.
- **easel:** `GET /api/projects/{id}/people?q=` (anyone on the project;
  outsider 404) = `interaction.people()` over `_project_audience`, names
  via `bw_auth.userinfo` cached 10 min (`display_name`). Wired into the
  discussion panel's three composers and InlineThread. `tests/test_people.py`.
- **The button:** `.vtb-disc` with `CommentIcon`; `.is-nudge` while closed:
  a 14s cycle, a hop and a glow in its first second, none under
  reduced motion. Live: class and animation present closed, gone open.
- Verified live: `/people` lists Adi Pramono, Darren Williams, Rian
  Bowden, Test Account with names from the directory.
- **0.59.1:** the debounce depended on the `mentions` function, an
  inline arrow re-created on every panel render, and the panel re-renders
  on every poll, so the search never ran (found live: no request, no
  list). The function now sits in a ref and the effect depends on the
  query alone. Verified live end to end: "@da" → request → "Darren
  Williams @darren" → Enter inserts `@darren ` → Escape closes the list
  and the panel stays. Note for pack users: a hidden tab throttles the
  120ms debounce to a second; wait longer than that in JS-driven checks.
- Not mine: `main/frontend/src/bw-admin/SignIn.tsx` has an uncommitted
  edit from another session today (a `next=` on the forgot link). Left.

## 2026-09-07 (rian's session) Mail goes out, a digest an hour, the welcome once (v0.58.0)

rian, from testing as darren and planning his own tests: (1) after the whole
walkthrough he lands on the welcome again; (2) Adi's test comments fill the
bell; (3) email must work: forgot-password for the team, the team told on
Feedback complete, a digest after 60 quiet minutes of new bell items, the
bell following review's rules; (4) test@rian.ca as a client member to test
with.

**(1) The welcome.** Two causes. `should_offer` was `seen < VERSION`, so
every content bump (three today) re-offered it; now `seen == 0`, once ever.
And the "seen" write is refused in read-only View As (`VIEW_AS_READ_ONLY`),
so darren-as-rian could never be marked; the board now also remembers in
`localStorage` (`easel:intro-seen:<project>:<user>`) on skip, finish or
hand-off. Verified as darren via View As: offered once, skipped, gone on
reload.

**(2)** Sixteen notification rows on league (Adi presenting to darren and
rian, rian replying) deleted with a one-off in the app container.

**(3) Mail.** easel had an outbox and no transport (outbound.py said
connecting the gateway was rian's decision; he made it). The transport is
now the account service:
- gateway `POST /api/app-send-mail` (`handle_app_send_mail`): secret
  verified, scoped on the UNFORGEABLE authed store (only a user who has
  signed into the app), address from the id-users store, caps
  `ID_APP_MAIL_DAILY_CAP` 300/app/day and `ID_ACCOUNT_MAIL_DAILY_CAP`
  40/account/app/day in their own buckets, subject prefixed with the app
  name. Gateway restarted (passwordless sudo covers it).
- id-auth `POST /app/send-mail` forwards it (documented in its CLAUDE.md).
  **id-auth has NOT been restarted: that sudo needs a password. Until rian
  runs `sudo systemctl restart id-auth`, easel's sends fail and are held as
  `send_failed`, and the loop retries them for a day.**
- kit `bw_auth.send_mail(username, subject, text)` in the canonical
  `/srv/system/id-auth/app-auth/bw_auth.py` (ACCOUNTS.md documents it),
  re-vendored into easel byte-for-byte.
- easel `outbound._deliver` uses it; `retry_held` for `no_transport` /
  `send_failed` within 24h; Dev Mode holds stay held.
- `services/digest.py`: per recipient, unread + undelivered notifications;
  once the newest is older than `EASEL_DIGEST_QUIET_MINUTES` (60), one
  "New notifications in your Design Easel" email listing them (12 lines
  max), rows stamped `delivered_at` (the §7 seam, first written). Read in
  time = not mailed. `_mail_loop` in `main.py` ticks every
  `EASEL_DIGEST_POLL_SECONDS` (300) in production only; `POST
  /api/outbound/digest` (team) runs a tick now, `quiet_minutes: 0` to skip
  the wait; the dev-mode bar has "Send digests now".
- Bell rules already match review (mention; reply in a thread you are in;
  turn). Added the one gap: a self-mention (review 0015).
- Feedback complete already rang the team's bell and queued mail; it now
  arrives (once id-auth is restarted and Dev Mode is off).
- Forgot password: id-auth's `/forgot` → gateway `/api/id-request-reset` →
  Resend. Already in place; the invite below proves Resend sends.
- Verified live as rian: a self-mention lit the bell, "Send digests now"
  queued one digest, held `dev_mode` (Dev Mode is ON). Test note removed.

**(4)** `POST /api/bw/instances/league/invite` as rian: `test` /
test@rian.ca / member (= client side). Response: "invite emailed to
t***@rian.ca". The 48h set-password link is in that inbox.

**For rian to do:** restart id-auth; turn Dev Mode off on the bar to let
mail leave; set test's password from the invite email; then test forgot
password, Feedback complete as test, and a mention or reply with "Send
digests now". 260 tests.

## 2026-09-07 (rian's session) Three introduction points out, the variations switch in (v0.57.1)

rian: remove "Why the photos are blurred" and both restart points (board
"Restart any time", viewer "The walkthrough"); add a point that highlights
the options that can be turned on and off. Done in `lib/toolTour.ts`: part
1 is welcome, stages, concepts, comments, prefer, feedback (the hand-off
sentence moved onto feedback); part 2 is where, **variants** (target
`[data-tour="variants"]`, the segmented switch in the app bar; skipped by
the usual rule on a design with no axis), notes, discussion, bell (the
closing sentence moved onto the bell). TOOL_TOUR_VERSION 4. Also today,
outside git: the league facts column is padded and centred both ways
(rian's ask after the screenshot).

## 2026-09-07 (rian's session) rian's eleven notes on the league designs, done (v0.57.0)

rian left eleven "FOR CLAUDE" comments IN EASEL, on the two league concepts
(three area notes, eight walkthrough-point comments), and asked for them to
be acted on and removed. That is a new kind of session input: the tool is
the brief. Found through `GET /api/options/{id}/discussion` as rian.

**A walkthrough point can be a card.** `walkthrough_steps.modal` (migration
`e6a8b0c2d4f6`), on `StepOut`/`CreateStepIn`/`UpdateStepIn`, the editor's
"Say this in a centred card, with no highlight", `TourModal` gained
`eyebrow`, `skipLabel`, `veil` and takes rich children (`Prose`). The
Viewer draws no Spotlight for a modal beat and renders the card over the
veiled design; Next walks on, or into the next concept, or finishes.

**The Direct Answer (option 1), both bundle files, byte-identical:**
- `.gal` is now "From our lawyers": six article cards (`.shot.post`:
  blurred picture, date, title, Read article). Three titles are the live
  site's, three are placeholders (marked in the HTML comment). The strip
  script is untouched (same card width, six cards, three visible).
- The `.dyk` band is gone; its markup lives in `.knw` as `.facts.dyk` (the
  facts script keys on ids and `closest('.dyk')`, so it still runs).
  Columns swapped: `.faq` left (flex 1), `.facts` right (512px, the rule).
- `.locmap svg, .locmap .pin` blurred 10px, the "Embedded map" tag crisp;
  `.cmodal .mimg img` blurred.
- Walkthrough: point 1 is the card ("The concept walkthrough", rian's
  words, two bullets); "Services, one hover away" targets
  `.services .mi:first-child .mega` with a demo click on the item's link,
  hold 0 (the bridge's Escape closes it on leaving); the photo point is
  "From our lawyers"; facts → `.knw .facts`, map → `.locmap`, local page →
  `.lgrid .loc:first-child`; new point "Common questions" → `.knw .faq`.
  Order: welcome, services, phone, issue, league, bench, articles, map,
  local page, facts, common questions, two decisions, sticky CTA. The
  page shrank from 4688px to 4442px at 1425 wide, so the hand rects above
  the change (phone, issue, bench, sticky CTA) were scaled by 4688/4442
  to keep their pixel positions; the articles rect got the taller section.
- Verified live by DOM at every point: the menu measures 855×226 (the
  whole open menu), the map 855×254, facts and questions side by side.

**Option B (option 5):** "Your legal matter:" → "How can we help?"; the
same two blurs; a card first: "Option B" in rian's words. Verified live.

**The comments are gone:** the three pins deleted, the eight point comments
soft-deleted (the panel hides threads with no live comment; the API still
shows `deleted: true`, standard §4).

**Not done:** nothing asked. Note the three placeholder article titles are
invented (marked); swap them when the real posts are chosen.

## 2026-09-07 (rian's session) Two parts, and the timeline flips for the reader (v0.55.1, v0.56.0)

rian, after walking v0.55.0: no filler line under a card point; Start and
Feedback complete compact, without the explanations (0.55.1). Then: two
parts instead of three, in the order 1, 3, 2, so the concepts page is
reviewed first and the review screen second; and on the timeline point,
demo a click on the scope stage flipping over and back (0.56.0).

- `TOUR_BOARD_STEPS` now carries the old closing points (comments, prefer,
  feedback) before `start`; `TOUR_CLOSING_STEPS` and the board's "closing"
  mode and `?tour=3` are gone. `TOUR_PARTS` has two entries. The viewer's
  last button is "Start the Design Concept Walkthrough": `endTour(true)`
  then `setTourIndex(0)` in place (or the board when there are no points).
- The timeline point: target `[data-tour="journey"]` (the whole `.jr`
  section, rail and stage), `demo: "stages"`. `DemoStages` (TourDemo.tsx)
  is a fixed pointer moved by timers: 0.7s to the first stage, 1.6s press
  and `showStage("first")`, 3.6s to "Back to now", 4.4s press and
  `showStage("now")`, 5.2s gone. `StageJourney` listens for the
  `easel:tour-stage` window event and sets its selected stage; the demo's
  cleanup sends "now" so leaving the point mid-flip restores the rail.
- Verified live by DOM: Part 1 of 2, seven points; the stage heading reads
  "Scope and Initial Direction" at 2s and "Initial Concepts" again at 4.7s
  with the pointer over the right buttons; the hand-off into the viewer;
  the last press opening concept 1 at step 1 with the tour marked complete.

## 2026-09-07 (rian's session) The tool introduction in three parts, then the designs (v0.55.0)

rian's second message today, in full: the whole tool walkthrough first, then
the design walkthroughs. Three parts (board intro, over to the viewer to show
how to review, back to the board for general comments and the preference).
The first welcome as a centred modal, highlighting the sidebar. Highlight the
bell. Simulate leaving a comment by clicking an area. At Discussion, pop back
out to a modal with simulated comments and replies in the sidebar. After the
tour the button reads "Start the Design Concept Walkthrough" and starts
concept 1 then 2. He revised all the commentary (tone set; grammar only from
here). On the board: Start and Feedback complete above the fold, right of
the intro text; the button "Start the Design Concept Walkthrough" with "Start
the Full Walkthrough (including tool introduction)" under it.

**Landed, all of it.**
- `lib/toolTour.ts`: `TourStep` gained `modal`, `demo` ("note" |
  "discussion"), `layer` ("panel" | "top"), `scroll` ("start"). Three lists
  as before, new points: `bell` (part 2), `comments` (part 3). Shipped words
  are rian's revised ones, grammar-fixed. `TOUR_PARTS` names the parts; the
  panel subtitle reads "Part 2 of 3: Reviewing a design · Point 4 of 6".
- Route: board `?tour=1` (part 1) → viewer `?tour=1` (part 2, no design
  step) → board `?tour=3` (part 3) → last button `?mode=play&step=1` on the
  first concept with a walkthrough → concept 2 via the footer → plain board.
  `?tour=done`, `introRef` and `&intro=1` are gone. Part 3's exit or finish
  marks the tour seen.
- `components/TourModal.tsx` (new): the centred card in the space beside
  the panel (z 57), same Skip/Back/Next, the team's Edit inside it.
  `ToolTourHighlight` draws the ring ABOVE the panel for `layer: "panel"`
  (z 56) and a dim band with the same hole over the global row for
  `layer: "top"` (z 61/62), both outside `tt-root` because that is its own
  stacking context at 52. `scroll: "start"` puts a tall design's top under
  the bars instead of centring it.
- `components/TourDemo.tsx` (new): `DemoNoteStage` (pointer drags a dashed
  box, marker lands, CSS keyframes, plays once) + `DemoNoteCard` (the
  comment appears in the panel after it), and `DemoDiscussion` (three
  example threads built from the pack's ThreadCard/ThreadReplies, the team
  signing as "The team"). Nothing is saved.
- Board: `.jr-review-top` grid, words left, `.jr-acts` right (Start the
  Design Concept Walkthrough, the full-walkthrough link, Feedback complete);
  concepts under both. `data-tour` on the cards' Comment and Prefer links,
  and on the bell (`App.tsx` wraps `EaselBell`). `firstTime` is gone from
  StageView: the design button never folds the tour in.
- `TOOL_TOUR_VERSION` 3. League's eleven overrides were cleared (they were
  rian's words, now shipped; leaving them would have shadowed the grammar
  fixes and the new sentences about the panel and the example).

**Verified live, as rian, by DOM** (screenshots time out on the hidden tab
with the blurred overlay): the full route end to end, every ring within 6px
of its target (nav, walkthrough button, panel, bell, comment link, prefer
link, feedback button), the demo box and card present on point 3, three
cards and two replies on point 4, no panel footer on modal points, the
final press opening concept 1 at step 1 with `tool_tour.completed: true`.
Not seen with eyes: the demo animation and the card's look. rian should
look once at each in a real tab.

**Still not done:** the `WalkthroughPanel` outline's "Replay the
introduction" replays part 2 only; the Team tools' replay starts part 1.
Fine for now, but the outline could say "part 2".

## 2026-09-07 (rian's session) The introduction in the team's own words, from the welcome (v0.54.0)

rian: "we used to have the ability as admins to edit the walkthrough
commentary and reposition the highlights. I'm trying to go through the whole
walkthrough including the initial welcome, but I'm having trouble doing that
as an admin and also I want to make changes as I go through."

**What was actually wrong.** The design walkthrough's Edit and Adjust box
were still there for managers (verified live on 0.53.0). The tool
introduction was not editable at all: its points lived in
`lib/toolTour.ts`. And a manager never met the welcome: it ran only for a
first-time client, and every team entry (Team tools "Replay", the outline's
"Replay the introduction", Start the walkthrough) began past it.

**Landed.**
- `project_details.tour_overrides` (JSON, migration `d5f7a9c1e3b6`, parented
  on Adi's `a1c9f3e7d5b2`), `PATCH /api/projects/{id}/tour/{key}` (manager
  only; empty title+body clears the key), `ProjectDetail.tour_overrides`.
  `applyOverrides()` in `lib/toolTour.ts` lays them over the shipped points
  wherever they render: `BoardTour` (welcome and closing), the viewer's
  segment. The outline in `WalkthroughPanel` still shows shipped titles for
  the introduction section; a small follow-up if anyone renames a point.
- `TourPointEditor` (exported from `BoardTour.tsx`, used by the viewer too):
  title, body, `{name}` hint, "Use the shipped words" when overridden, the
  shipped words folded underneath. Every introduction point shows Edit for
  managers, in place.
- Board: "Start from the welcome" under Start the walkthrough, for everyone
  (`onStartIntro` through `StageJourney` to `ReviewStage`).
- Test: `test_the_team_can_say_the_introduction_its_own_way`. 254 passed.

**Verified live, as rian:** board link → welcome → Edit → save (API shows
the override, button reads "Edit (this project's words)") → "Use the shipped
words" (override gone) → Next through the four board points → viewer
introduction, point 1 of 5, Edit opens there too. League carries no
overrides; the test one was reset.

**Not done, deliberately:** repositioning the introduction's highlights. They
are fixed to the chrome they point at (`data-tour` targets), so there is
nothing to drag; the design walkthrough's Adjust box is the repositioning
tool. If rian meant that one and it misbehaves, that is a new report.

## 2026-09-03 (Adi) Variations built and in use, the copy sweep, walkthrough
authoring, and six bugs found by using it

**Against the two asks in rian's 2026-09-03 message that concern easel:**

1. **"easel still does not have the variations feature."** It did, shipped the
   previous afternoon. Nothing used it and the way in was a small button
   inside a folded section, so from the outside it did not exist. That is a
   fair verdict on the feature rather than a mistaken one, and the work today
   was making it real: see below. It is in use on league now.
2. **A human review of easel, especially whether the walkthrough pitches the
   ideas properly.** Half done, and the half that happened was worth it: Adi
   used it as a manager for a full session and found SIX faults no test would
   have caught (below). The pitch review itself is only started: copy was
   drafted for three points (the mega menu, the league claim, the bench) and
   Adi has not entered them yet. The rest of the walkthrough has not been read
   line by line.

**Coming to easel:** a second project, from another job whose homepage
mockups are being scoped elsewhere, with client users on it. It waits on the
mockups existing. rian's other asks in that message belong to their own
projects and are recorded there, not here.

**Variations, finished and in use.**

- Authoring moved off the viewer's Settings tab (a team tool inside the
  client's conversation, one tab deep in a panel nobody opens) onto the board,
  as a child line of the option row: a screen has options, an option has
  looks. The way in is a "Variations" button beside Rename and Remove,
  carrying its count.
- **Setting a toggle up is naming the looks.** The bundle already declares
  which classes it can wear (any rule scoped to `<html>`, which IS the
  mechanism the bridge uses), so `declared_root_classes()` reads them back,
  the panel lists them, and one press builds the axis, the base choice, the
  keys, the order and the default in one transaction
  (`POST /api/options/{id}/variants/setup`). Before that, an admin had to know
  that the plain side needs its own choice with an EMPTY class or there is
  nothing to switch back to. Nobody should need to know that.
- An option whose design declares an unwired class says so on the board. An
  option whose design declares nothing says THAT, and points at Replace
  design, instead of offering a form that cannot succeed.
- The switch sits in the app bar's centre slot as a segmented control, not a
  band of its own above the design.
- **The upload control only rendered while an option had no files**, so a
  design already in front of a client could never be replaced from the board.
  Always present for the team now, reading "Replace design".
- League's three homepage builds were converted IN PLACE on disk rather than
  re-uploaded: the hive markup was always there, hidden by three rules, so
  scoping the hiding to `:not(.v-brand-hive)` leaves the default rendering
  byte for byte identical and every note and walkthrough point keeps its
  anchor. Backups in the session scratchpad.

**Walkthrough authoring.**

- **Reorder** (`POST /api/options/{id}/walkthrough/reorder`): the whole
  ordered list, renumbered in one transaction, because nudging one point at a
  time leaves it briefly scrambled for whoever is reading. The editor shows
  `Point [3] of 6` as a field: type the position, press Enter. Earlier and
  Later were tried first and Adi was right that they are the wrong control for
  "this belongs first".
- **The demo click waits while the point is read**
  (`click_delay_seconds`, default 2, per point). It used to fire 260ms after
  the beat opened, so the design changed while the reader was on the first
  line.
- Moving a point keeps its editor open, on the point that moved.

**The client-facing writing is swept of em dashes** (rian's rule, now in
`agents.md`): the tool introduction, the stage view, the dialogs, the roadmap
prose seeded into every new project, the League seed copy, and the three
emails a client can receive. Rewritten rather than search-and-replaced. Code
comments are untouched: that is rian's own sweep. Still carrying one:
`We are teams of legal professionals supporting clients —` and
`Teams of legal professionals — lawyers and staff` in the mockups, waiting on
Adi's wording.

**Six bugs, every one found by a person using the app, none by a test.**

- Removing an option's last variation blanked the board. Four hooks after an
  early `return null`, so the component called ten hooks on one render and six
  on the next. Nothing here would have caught it: there is no linter, so
  `react-hooks/rules-of-hooks` has never run, and both `tsc` and the
  production build pass on it.
- Marking an area threw the selection to the footer. The step composer wore
  `.tour-card`; when the floating tour card was deleted in v0.33.0 its CSS
  went too, so the composer rendered in flow at the foot of a very tall page
  and its `autoFocus` scrolled the browser there mid drag. Own class now, and
  `preventScroll`.
- The Team menu opened off the edge of the window. `caddie-ui`'s `Menu` was
  `left: 0` always. Fixed in the PACK (easel holds the canonical copy): the
  popup measures itself on open, flips when it would overflow, re-checks on
  resize. README row, plus a rule in the standards proposal: anything that
  positions itself against the viewport owns staying inside it.
- A demo click could not open the nav at all. The menus are pure CSS on
  `:hover, :focus-within` and `element.click()` does not move focus, so the
  bridge focuses first now.
- Which exposed the next one: every link in these mockups is `href="#"`, the
  guard read a bare hash as a legitimate fragment, and a real click on one
  scrolls the document to the TOP. Bare hashes are neutralised; real fragments
  still navigate.
- And the one under that: **the beat's own dismissal was ending the
  walkthrough.** The frame forwards Escape so a real key press reaches the app
  (v0.36.3), and `sendEscape()` dispatches a SYNTHETIC Escape to close what
  the demo click opened. The forwarder could not tell them apart, so the
  walkthrough ended a few seconds into any beat with a demo click. Only
  `isTrusted` events are forwarded now, the effect is keyed on the beat rather
  than the whole steps array (a refetch was re-firing it), and Escape closes
  the smallest thing that is open, so leaving the editor no longer throws away
  the walkthrough.

**Not done / next.**

- **Insert a point AT a position.** Creating always appends, so a new point
  lands last and has to be moved. The reorder half exists; the insert half
  does not.
- **A linter.** `eslint` with the react-hooks plugin would have caught the
  blank-board bug before it reached Adi. Not added unilaterally: it would want
  to be true for every caddie app, so it is rian's call.
- The pitch review of the walkthrough, points 2 onward.
- `ContentsPane` is still unused. Adi agreed to its removal on 09-02; it is
  rian's to delete.

## 2026-09-02 (tidy for Adi) league cleared of test material

rian: clear the test comments and get it tidy for handoff to Adi (not to the
client yet). Removed from league: all nine notes on the design (every one a
test string by rian or adi), the page and Option 1 test conversations, the
two walkthrough-point conversations left by the attachment tests, the five
test images in `data/uploads/league/`, and the test preference on The
Direct Answer (screen 1 selected_option cleared). Kept: walkthrough points,
stage bodies, concept blurbs and thumbnails, the project blurb, events.
Not touched: notifications (test noise in rian's and adi's bells; darren's
"presented" notification from the earlier send is real), tour seen-state.
The project reads "Awaiting review" with an empty discussion, no preference,
no Feedback complete. Ready for Adi to pick up; not yet presented to darren
for real.

## 2026-09-02 (checkpoint, end of rian's session with Claude) v0.45.2 live; standards review 2 written

**State.** Deployed and committed: v0.45.2. Tree clean at `ab182ad`. Backend
suite 239 green. Everything from the last checkpoint (v0.32) forward is in
the entries below this one; the standards input for the caddie session is
`.logs/planning/caddie-standards-review-2.md` (pass 2) with pass 1 pointing
at it, the pack README current, and a pointer entry in caddie's runbook §5.

**Where the app is, in one screen.** Board = the project's journey (stage
rail, the active stage's concepts, Start the walkthrough, Feedback complete;
team tools folded). Viewer bar = navigator, preference mark, Walkthrough,
Discussion, Team menu. One Discussion mode: panel open = comment mode; every
subject listed (page, version, walkthrough points as roots, notes on the
design), conversations inline under their card, breadcrumb labels, re-file
between subjects, attachments with a lightbox, area notes drafted on press
and movable after. The tool introduction is a walkthrough that starts on the
board, hands into the viewer's points and the design walkthrough, and closes
on the board. League is seeded (stages, scope copy, blurbs, thumbnails).

**New rule to obey from here (ab182ad, agents.md): no em dashes in anything
an AI session writes here.** It binds new writing; the rule itself says
existing text is not swept. Today's client-facing copy was written before it
and uses them (tour points in `lib/toolTour.ts`, the roadmap bodies in
`services/stages.py`, hints in `StageView.tsx` and `DiscussionPanel.tsx`,
the league stage bodies in the database). If rian wants those cleaned, that
is the list; historical handoff and changelog entries stay as they are.

**In flight / not done.**
- `ScreenNotes` on the board still uses its own textarea (no paste, no pack
  Composer). Small refactor, Adi's file.
- `ContentsPane` is unused since the viewer's Contents tab went; delete when
  Adi agrees. `VariantAuthor.tsx` is Adi's (untracked at one point; check).
- Pack candidates still living in easel: `Spotlight` + `ToolTourHighlight`
  (a `Highlight` piece), `BoardTour` + the stepping logic (a `Tour` piece),
  `InlineThread`. The review lists the full inventory.
- Keyboard nudging of markers; moving a point note into an area; moving a
  conversation ONTO a spot.
- The `attachment` table and `CommentOut.attachment_ids` are unused (the
  addendum model carries files in the body); drop with a migration.
- Adi's session works in the same tree: commit by explicit path.

**Next, when rian returns.** Whatever the caddie session decides about the
pack, stages ownership and the tour hand-off (review §9); then the em-dash
sweep; then darren.

## 2026-09-02 — v0.45.2: highlight jumps, and a lesson about background tabs

Chasing a "stale" highlight box: the component's state and inline style
were right on every point; `.tt-spot` had `transition: all 180ms`, and a
BACKGROUND tab freezes CSS transitions (as it pauses rAF — see 0.36.5), so
getBoundingClientRect and screenshots showed the start value. Removed the
transition (a box that lags the words is worse than one that jumps). Rule
for live checks from the MCP tab: it is hidden — never trust a measurement
that depends on rAF, smooth scroll or a transition having run.

## 2026-09-02 — v0.45.1: the highlight re-measures on layout changes

`ToolTourHighlight` watched scroll/resize only; the panel pushing the page
and the stage settling its width moved the target underneath it (the
stage's box sat stale). Now a ResizeObserver on the target and the body plus
two delayed looks. Verified: the board opening (welcome veil, stage line
spot), the hand-off into the viewer's five points, the design walkthrough,
and the close on the board.

## 2026-09-02 — v0.44.0–0.45.0: inline conversations, point-as-root, lightbox; the introduction starts on the board

- **0.44.0 — the list stays a list.** `DiscussionPanel` rewritten: no
  drill-in. `opened` (pin / page / version / point) is the card whose
  conversation is expanded inline (pack `ThreadCard` `expanded` + children;
  new `ThreadReplies`); Reply toggles it; the card click goes to the design
  (pins) or does nothing (page/version); point cards are the POINT itself
  (`pointRow`: body = the step's words, author "Walkthrough", all comments as
  replies; `PointThreadOut.body_md` added). Starters expand a composer
  inline. rian's "replies don't post" was the old thread view not refreshing
  — the reply landed; gone with the drill-in. `Lightbox`/`openLightbox` in
  the pack; `Body` images open it; mounted once in App.
- **0.45.0 — the introduction on the board.** `lib/toolTour.ts` now has
  `TOUR_BOARD_STEPS` (welcome, where we are, the concepts, next: the
  walkthrough), `TOUR_STEPS` (the viewer's five), `TOUR_CLOSING_STEPS`
  (your preference, when you are done). `components/BoardTour.tsx` renders
  a segment in the SidePanel with `ToolTourHighlight`; ProjectOverview opens
  "intro" on `?tour=1` or a first visit (should_offer && !manage), "closing"
  on `?tour=done`; the board's Team-tools replay starts it here. The Viewer
  carries `introRef` (from `?tour=1`/`?intro=1`) across versions
  (`goToOption` appends `intro=1`) and the last Finish goes to
  `/p/{id}?tour=done`. Targets: `data-tour="stages"|"option-list"|"start"|
  "feedback-complete"` on the board.

## 2026-09-02 — v0.43.0: the tool introduction as a panel walkthrough

rian: redo the tour in the new style — commentary in the sidebar, highlights
on the page, "Tool Introduction" instead of "How to use this", personalised,
full-page dim for points with no target. Done: `lib/toolTour.ts` is the
content (8 points, `{name}` placeholder, `personalize()`), `ToolTour.tsx` is
now `ToolTourHighlight` (dim + spot in px, or `.tt-veil` full dim — both
blurred; z 52, under the panel), the Viewer owns `toolIndex` and builds the
panel's `tour` content from `TOUR_STEPS` (Timeline + body + Skip/Back/Next/
Finish; `goTool` skips points whose target is absent); `data-tour=
"walkthrough"` on the ▶ button. `TOOL_TOUR_VERSION` bumped so everyone is
offered it again. The outline section and the contents read say "Tool
Introduction". Old bubble CSS removed.

## 2026-09-02 — v0.42.0: re-file a conversation; breadcrumb labels

- `svc.move_thread(db, source, target, project_id, actor)` re-parents the
  comments through the relationship (delete-orphan!), carries `resolved` to a
  fresh target, deletes the pin row (if any) then the empty source thread;
  `POST /api/threads/{id}/move` (pins.py) resolves screen/option/step targets
  within the project (outsider → 404; non-author client → 403).
- Panel: `Move to…` (pack `Menu`) above an open conversation, for the root's
  author or the team; labels via `pageLabel/optionLabel/pointLabel`;
  `optionNumber` prop (index of the option on its screen, 1-based).
- Not done: moving a conversation ONTO a spot (needs a click on the design).

## 2026-09-02 — v0.41.0: movable/resizable markers, card-click-to-canvas, blurred surrounds

- Markers: `Pin` takes `canMove/stageRef/onMove` — pointer-captured drag
  with a 0.4% threshold (a still press = open); an open area note renders the
  walkthrough's `SpotlightAdjuster` for the author/team → `useMovePin` with
  w/h (`MovePinIn` grew both-or-neither `w_percent/h_percent`).
- Card click vs Reply: pack `ThreadCard.onSelect` (card) vs `onOpen` (Reply);
  the Viewer now has `focusPin` (highlighted on the canvas) separate from
  `openPin` (thread open); `highlightPin = openPin ?? focusPin` drives the
  marker's `is-open` and the area spotlight; `onLocate` sets focus only.
- Highlights: `Spotlight` component = `.spot-dim` (full-stage overlay, dark +
  `backdrop-filter: blur(3px)`, `clip-path: polygon(evenodd, …)` cut-out) +
  the `.spotlight` box; used by the beat, area notes, both drafts. ToolTour
  got the same (`.tt-dim` in px). `box-shadow: 0 0 0 9999px` is gone.
- Not done: keyboard nudging of markers; moving a POINT note into an area.

## 2026-09-02 — v0.40.0: area notes, the draft marker, click-to-locate

rian's three asks on the notes. (1) Opening a note card in the Discussion
list also scrolls to it and highlights the marker (`onOpen` → `onLocate`).
(2) The marker shows the moment the mouse goes down (`.pin-draft-marker`,
pulsing) — the draft — and nothing is saved until posted. (3) Press-and-drag
marks an AREA: `pins.w_percent/h_percent` (migration `c4e6a8b0d2f4`; NULL =
point), `CreatePinIn` takes both or neither; the stage's comment-mode
handlers are now down/move/up/leave (`onNoteDown/Move/Up`; a 0.6%-of-width
threshold separates a click from a drag; a press on an existing marker is
left to the marker); an open area note renders the walkthrough's `.spotlight`
(`.is-note`, the note colour), closed ones a dashed `.pin-area` outline. The
authoring drag (managers) is untouched — the handlers switch on `authoring`.
Not done: moving a marker after posting (the old copy promised it; `useMovePin`
exists but nothing calls it); resizing an area after posting.

## 2026-09-02 — v0.39.1–0.39.2: viewer crash fix, hint copy

0.39.0 shipped a null dereference in `DiscussionPanel` (point lookup with no
subject selected) — the viewer went blank on open; caught by the live check
minutes later, fixed in 0.39.1. 0.39.2 fixes the comment-mode hint that still
said "Play hides the notes". Verified live: bar = navigator · ☆ Prefer this
one · ▶ Walkthrough · Discussion (n) · Team ▾; Discussion open → stage in
comment mode with the notes shown, panel groups In general / On the
walkthrough / On the design, starters drill into a composer.

## 2026-09-02 — v0.39.0: the viewer's bar cleaned up; one Discussion mode

rian: no Comment/Play; one "Discussion" that opens the sidebar with every
kind of comment (whole design, this version, walkthrough points, spot notes)
as items with drill-down; clicking the design in that mode drops a note; a
way to re-run the walkthrough; the preference mark beside the navigator; no
Copy link / Contents.

- `GET /api/options/{id}/discussion` (board.py) — pins + page (screen
  thread) + version (option thread) + points (step threads) in one read;
  `DiscussionOut`; every conversation mutation invalidates `["discussion"]`.
- `DiscussionPanel` rewritten: groups "In general" (page, version — starters
  when empty), "On the walkthrough" (points with comments), "On the design"
  (pins); drill-in via ThreadView; first comment on a subject posts through
  its own route, replies through the same route (they "open or continue").
- Viewer: `mode` is DERIVED (`panel === "discussion" && tourIndex === null`);
  `?mode=comment`/`?drawer=1`/`?pin=` open the panel; the walkthrough closes
  it. Toolbar: navigator · `.vtb-pref` · ▶ Walkthrough · Discussion (count
  across all subjects) · Team menu (author / import / copy link). `CopyLink`
  removed. Tool-tour targets `mode-toggle`/`comment-tab` no longer exist
  (those coach marks skip themselves) — the tour is being redone anyway.
- Not done: the Contents tab is gone from the viewer (the walkthrough outline
  covers it); `ContentsPane` is now unused — remove when Adi agrees.

## 2026-09-02 — v0.38.1: tool tour folded into Start, greeting by name, client-facing scope copy

rian: "redo the whole walkthrough at the end, but get it into the new format
now" → the board's welcome popup is gone; a first visit's Start the
walkthrough carries `tour=1` and the Viewer holds `wantedStep` until the tool
tour ends (`[steps, tourOn]`). "Hi Darren — 2 concepts are ready for you."
and "Thanks, Darren — …" from `firstNameOf(username)` (the account payload
has no display name). League's scope stage rewritten (what we're designing;
the direction you gave us) — via a one-off script in the container; the text
is editable from the board's stage tools. The tool tour's CONTENT is still
the old five coach marks — rian wants the whole walkthrough redone later.

## 2026-09-02 — v0.38.0: the board is the project's journey (stages, the review stage, Feedback complete)

rian's ask: title → stage tracker (six stages, done/here/next obvious, step
back to what was collected, forward to what is coming) → the current stage
below it: two concepts with thumbnails + a line each, one big Start
walkthrough, quiet look/prefer, general comments per concept, a big Feedback
complete that tells the team. Plan + decisions:
`.logs/planning/board-redesign-stages.md`.

**Landed** (backend suite 235 green; deployed; league seeded):

- **Stages** in caddie's shape (`project_stages`, `services/stages.py`
  ROADMAP template, seeded on first read; `GET/PATCH /api/projects/{id}/
  stages[/{stage_id}]`; on `ProjectDetail.stages`). Managers edit text/status
  from the board (StageText tools). Timestamps follow status.
- **Feedback complete** (`POST /api/projects/{id}/feedback-complete`):
  `feedback_completed_at/by` on the project; rollup → `waiting_agency` unless
  done; `send_project` clears it; bell `turn` + outbound mail to managers.
- **Concept threads** (`subject_type="option"`), rollup-counted; blurbs
  (`Option.blurb`, PATCH option); **thumbnails** (`Option.thumbnail_name`,
  `POST/GET /api/options/{id}/thumbnail`, attachments rules). Migration
  `b8d4f2a6c1e3`.
- **Frontend:** `components/StageView.tsx` (StageJourney → StageText /
  ReviewStage → ConceptCard, FeedbackComplete), `InlineThread` (shared with
  walkthrough points), pack `Stages` + `Prose`; `ProjectOverview` rebuilt
  around it with Adi's screen/option cards folded into "Team tools"; the
  viewer's Finish on the last concept returns to the board.
- **League content:** stage 1 body from the leaguelaw proposal (Option 2
  scope) + Darren's 10 Aug direction; stage 2 line; blurbs for The Direct
  Answer / The Welcome Desk; thumbnails rendered headlessly from the bundles
  (google-chrome on the host; the seed script is in the session scratchpad,
  not the repo — a manager uploads pictures from the card from now on).

**Git note:** Adi's a103477 (v0.37.0) swept `services/stages.py` and the
migration into his commit while they sat in the shared tree; the rest lands
in the v0.38.0 commit. The tree is shared — `git add -A` from either session
takes the other's files.

**Not done / next:**
- The tool tour's welcome offer still fires on the board; its coach marks
  target the viewer. Probably move the offer to "Start the walkthrough".
- Stage reordering/adding, target dates: caddie's.
- `ContentsPane` is no longer on the board (still in the viewer's panel).

## 2026-09-02 — v0.36.3: the walkthrough's exit was hidden under the global bar (dev strip); Escape now reaches the app from the design

rian could not find how to leave a walkthrough. Cause: the dev-mode strip
(36px) sits above the sticky global row; the side panel was pinned at the
row's NOMINAL height (`--cu-global-h`, 40px), so its head — the × and the
"‹ Exit walkthrough" back link — rendered under the row (z-index 60 > 55).
A client without the strip saw them; the team never did. Fixes, all in the
pack where they belong: `GlobalBar` measures its bottom edge and publishes
`--cu-shell-bottom` (ResizeObserver on the row and body, scroll, resize);
`.cu-panel` pins to it. Plus an "Exit walkthrough" button in the footer
beside Back/Next, and the mockup bridge forwards Escape (only Escape) which
the Viewer replays on the document so panel/tour/menu close whether the
design or the app has focus. Rule + rationale recorded in the pack README
and the proposal doc (caddie's host bar will be exactly such a strip).

## 2026-09-02 — v0.36.2: pasted files stay out of the text box

rian: the markdown landing in the comment box was confusing. The pack's
`Composer` now holds uploaded files in its own pending list (thumbnail or PDF
tile, removable) and appends their markdown after the words on Send — a
comment may be a file alone. Wire format unchanged. `imagePrefix` is no
longer a Composer prop (still on ThreadCard/ThreadView/Body). README updated;
proposal doc carries the addendum wording amendment.

## 2026-09-02 — v0.36.1: the "Upload failed (403)" rian hit was read-only View As; the client now says so

rian pasted a screenshot while his session was viewing as someone else
(read-only) and got "Upload failed (403)". The 403 is the kit middleware's
`VIEW_AS_READ_ONLY` refusal — the only 403 it issues; the app's presence
heartbeat from the same session was refused too. Correct behaviour, useless
message: the middleware answers with a top-level `{error_code, summary,
details}` while routes wrap theirs in `detail`, and both `unwrap` and
`uploadAttachment` read only the wrapped shape. `apiError()` in `easelApi.ts`
now reads both; the composer shows "You are viewing as someone else,
read-only. Return to yourself to make changes, or start View As in act mode."
Same fix applies to every other write under read-only View As (they were all
"Request failed (403)"). Worth a line in the kit's UI standard: the SPA client
must parse the middleware's error shape too — every BW app has this gap unless
its client does.

## 2026-09-02 — Paste a screenshot into any note: the attachments addendum, in easel and the pack (v0.36.0)

**Answer to rian's question first:** the "paste an image like review" feature
WAS standardised — Interaction Standard §4 + the 2026-08-22 addendum, written
from punchlist v0.9.0 — but easel only had an unused per-comment backend stub
and the pack's `Composer` had no paste path. No Supabase needed: the standard
is DB-free files under the workspace `data/` volume, served through an
authenticated app route. That is now built here and is the pack's canonical
composer behaviour.

**Landed** (222 tests green, deployed, paste verified live on a step comment):

- **Backend** `services/attachments.py` (punchlist's, subject generalised to
  the PROJECT) + `POST /api/projects/{id}/attachments` (201 `AttachmentOut`:
  url/name/display/is_image/markdown) + `GET /api/projects/{id}/attachments/
  {name}`. Files: `data/uploads/{project}/{uuid}.{ext}`. Hostile-surface
  rules as the addendum lists them: png/jpg/gif/webp/pdf by extension AND
  magic bytes (no SVG/HTML ever), our filenames (NAME_RE = traversal guard),
  10 MB cap, 500 files/project, nosniff, content-type from our table,
  visibility re-checked through the project on every read, 404 never 403.
  `tests/test_attachments.py` proves each. The old per-comment stub
  (`/api/comments/{id}/attachments`, `/api/attachments/{id}`, `_sniff`) is
  retired with its test; the `attachment` table + `CommentOut.attachment_ids`
  remain (unused) — drop with a migration when convenient.
- **Pack** `Composer` gains `onAttach(file) → Promise<markdown>` +
  `imagePrefix`: paste (clipboardData.files), drop (dashed outline), the
  paperclip (hidden input, images+pdf); every file uploads at once and lands
  in the draft as markdown; a pending strip under the textarea shows
  thumbnails with ×; Send is disabled while uploading. `Body`/`renderBody`
  (new) is the one body renderer — markdown-lite, no innerHTML, images
  ONLY from `imagePrefix` (own relative URLs), links https or own.
  `ThreadCard`/`ThreadView` take `imagePrefix`. README has the section.
- **Wired** in the discussion panel (new note + reply), step comments, and
  the board's screen-note bodies (`ProjectOverview` renders through `Body`;
  its inline composer is Adi's own textarea and does NOT paste yet — see
  next).

**Not done / next:**

- `ScreenNotes` on the board still uses its own inline textarea (no paste).
  The standard says: use the pack `Composer` there too. Small refactor, Adi's
  file.
- The history view (`EditedLabel`) shows raw markdown — fine for a diff.
- Lift the backend half (service + two routes) into `bw_interaction` when
  caddie extracts the kit — the service is punchlist's with `url_base` and
  `subject_id` as parameters, so it lifts as-is.

## 2026-09-02 — The whole tour in the panel, comments on points, dots not numbers (v0.35.0)

**Landed** (v0.34.1 → v0.35.0, 155 tests green, deployed and verified live on
both directions):

- **The walkthrough panel is now the whole tour.** One `PanelSection` per
  design with a walkthrough plus "How to use this" (the tool tour); the
  section you are in is expanded and live, the others collapse to a
  progress read ("0 of 1 approved", "3 points") and open to their points.
  A point in another design jumps there — `goToOption(id, n)` →
  `?mode=play&step=n`. The data is the contents read: `ContentsItemOut`
  now carries `beats[]` (id, title, approval, comment count, open) so the
  outline needs no per-option fetch.
- **Comments on walkthrough points.** A beat is a subject on the Interaction
  Standard's seam, exactly like Adi's screen threads: `subject_type="step"`,
  one thread per point, created on the first comment, counted in the rollup
  like a screen's, notification url deep-links into the tour at the point.
  Routes `GET /api/steps/{id}/thread`, `POST /api/steps/{id}/comments`;
  `StepOut.comment_count` / `thread_open` badge the point. UI:
  `StepComments` inside the live point (list + pack `Composer`, Resolve /
  Reopen for managers). Tests: `tests/test_step_comments.py`.
- **"Ask a question" is gone** — the footer button that paused the tour and
  dropped a pin near the spotlight. The composer is on the point now. This
  is a feature change rian has been told about; pins for spatial notes are
  unchanged.
- **Timeline nodes are points, not numbers** (`Timeline` default; `numbered`
  prop keeps ordinals). `TimelineItem.badge/badgeTone` became `badges[]`
  (only consumer updated).
- **Fix:** arriving on another design with `?step=N` did nothing without a
  refresh — the viewer stays mounted across options and `wantedStep` read
  the URL once. Re-armed on `optionId` change.
- Pack: `PanelSection` added (README + proposal doc updated, incl. a new
  subject rule: put the composer where the subject is shown).

**Not done / next:**

- Edit/delete of a step comment in the UI (the generic
  `PATCH|DELETE /api/comments/{id}` already work — a manager can remove one
  from the API; the pack's reply-tools idiom would fit).
- The **Contents tab now overlaps the tour outline** (same sections, less
  detail). Candidate: fold Contents into the walkthrough view, or make the
  outline the Contents tab and open the tour from it. rian's call.
- Step-comment notifications carry the deep link; whether a comment with no
  mention notifies the other side follows `interaction.add_comment` as-is
  (same as screen threads).

## 2026-09-02 — Adi's session: M2 finished, six asks built, two production bugs found by using it

**Landed** (v0.5.0 → v0.27.0, 198 tests green, all deployed and verified live):

- **rian's four M2 asks are done.** Tool walkthrough on arrival · contents pane ·
  two options with toggleable variants (a variant choice is part of the locked
  direction; rollup enforces it) · option 2's reduced hero at **655px, measured
  from the live-site replica**, not chosen.
- **Six of Adi's own asks**: import a walkthrough from written notes · copy-link
  carrying the whole view (`pin`, `drawer`, `feedback`, `step`) · edit a beat and
  drag its highlight · demo clicks that open a reveal and hand the design back ·
  "new since you last looked" · presence ("has he even opened it?") · round
  summary (which also fills the contract's `actors_waiting`, empty since the
  rebuild) · Dev Mode + an outbox.
- **Screen-level conversations**, on the Interaction Standard's own
  `subject_type` seam. New capability — rian has not seen it.
- **Comment edit/delete with visible revision history.** An edit cannot silently
  rewrite the record; anyone who can read a comment can read what it used to say.
- **Reverted part-starring (v0.7.0)** — Adi's call: it was outside rian's mandate.
  Design is in the 0.7.0 handoff entry if rian wants it back.

**Two bugs that 188 green tests did not catch, both found by a person clicking:**

1. **Pins never worked on Postgres.** `create_pin` inserted the pin with
   `thread_id=0` before its thread existed; SQLite ships with foreign keys OFF
   and accepted it, Postgres refused. The app's central feature was dead in
   production from the rebuild until today. **The test engine now enforces
   SQLite foreign keys** — that is the actual fix, and the same gap had already
   hidden a dangling-selection bug on option delete.
2. **Every link in a concept navigated the frame to a blank page** — 52 per
   league homepage. Now cancelled in the bridge (anchors still work), with a
   toast so a dead click reads as out-of-scope rather than broken.

**In flight:** nothing half-done. Working tree clean.

**Next:** the **league board still carries pre-M2 content** — three old options
plus a stray test one, the seeded blurb, no reduced-hero bundle, no variant CSS
in the concepts. That is UI work (upload folder / remove / edit blurb) plus
Adi's design pass on the `v-brand-*` classes, and it is the last thing before
darren.

**Blocked / needs rian:**
- **`srv-gw security-audit`** — owner-only, and six migrations have landed since
  the last one.
- **The QA client credential** — `.qa-invite.txt` is 0600 rian; its 48h link has
  long expired. Without it nobody has tested commenting/choosing *as a client*.
- **Email transport** — the outbox and Dev Mode are built and tested; `_deliver()`
  is deliberately empty because the gateway's send endpoint is owner/hosting-peer
  only with its own allowlist. One decision unblocks it.
- **A nod on scope**: screen conversations, comment history, and the board/index
  rework are beyond the four asks.
- **league reads "Awaiting review"** — it has been presented. The rule was that
  rian does that himself once he has told darren. No mail has ever left easel,
  so darren was not notified either way.

## 2026-09-01 — Pins never worked on Postgres (v0.22.1)

Adi tried to post a note on the live board. Nothing happened and the composer
stayed open. The server log had the answer:

    psycopg.errors.ForeignKeyViolation: insert or update on table "pins"
    violates foreign key constraint "pins_thread_id_fkey"

`create_pin` inserted the Pin with `thread_id=0` as a placeholder and fixed it up
after the thread existed. `pins.thread_id` is a real foreign key, so **Postgres
refused the insert outright** — while **SQLite, which ships with foreign keys
OFF, accepted it silently**. Every test passed while the app's central feature
was broken for every real user, from the rebuild until now.

**The fix is two things, and the second matters more.**

1. Thread first, then pin. Nothing is written that points at something absent.
2. **The test engine now turns SQLite's foreign keys ON** (`app/db.py`). This is
   the real fix. The same dialect gap already hid a second bug — deleting a
   chosen option left `selected_option_id` dangling, found only by reading the
   code — and it would have kept hiding them. The suite still passes at 188 with
   enforcement on, so nothing else relied on the gap.

**RULE FOR THIS CODEBASE: tests must enforce what production enforces.** SQLite
in tests and Postgres in production is a standing invitation for referential
bugs to pass CI and fail users. If another divergence is found, close it the
same way rather than working around it.

**Second defect in the same report: it failed in SILENCE.** The note stayed in
the box, the button did nothing, and there was no way to tell a server error from
a mis-click — so the first sign of a broken core feature was a person saying
"nothing happened". Both the note composer and the reply box now show the error
and a pending state. A composer that swallows an error costs someone the thing
they just wrote.

**Worth stating plainly: this is what the browser pass is for.** 188 green tests
did not catch the app's central feature being dead in production. The gate has
not moved.

Live at v0.22.1, 188 tests green with FKs enforced.

## 2026-09-01 — The board is a list; the index is a worklist (v0.10.x-0.11.0)

Two rounds of Adi's design direction, both reversing something I had built, both
for reasons that hold up.

**The index (v0.10.0).** It was a grid of cards reading *name · state · 2 screens
· 3 options* — a directory listing that never answered the question you open it
with. Now rows, grouped **Needs you** / **Nothing waiting**.
- **`needs_you` is computed PER SIDE.** The same project is the team's move when
  client feedback is open and the client's when it is their turn. One project,
  two honest answers — baking it into the state would have forced one of them to
  be wrong.
- **Activity counts COMMENTS as well as events.** A reply is not an event, so a
  project whose only recent news is a client's reply would have read as idle —
  precisely the project you most want surfaced. Easy to regress; the test says so.
- `open_feedback` counts CLIENT threads only. Our own notes are not feedback
  waiting on us.
- Both from grouped queries, not per-project: the index is the one page whose
  cost grows with how much work you have.

**The app bar (v0.10.0-0.10.1).** A drawn mark, a **Team / Reviewing** chip, and
a real bug: `.app-topbar-right` had `margin-left: auto` and no `display: flex`,
so the bell — a block-level wrapper — was STACKING ABOVE the account menu
instead of sitting beside it. That was the untidiness, not the styling.
The chip is capability-derived, **never a level name** (the kit's own contract
forbids switching on one) and it flips during a View As, which is when it earns
its place.

**The board (v0.11.0): thumbnails removed.** Adi looked at them live: at tile
size the frame renders at ~10% scale, so a crop of the top of a page is a
silhouette — and the thing it strains to say ("no hero" vs "reduced hero") is
already beside it in words as the concept tag.

**The principle worth keeping, because it decides future arguments:** *the
board's job is navigation and status; comparing designs happens in the viewer*,
where they are full size and the variant toggles live. Once that is true,
thumbnails on the board are decoration. This reverses my v0.8.0 direction, and
the new information was simply seeing it at real tile size.

Each option is now a row carrying MORE than the tile did: chosen marker, name,
concept tag, locked variant labels, walkthrough beat count, "No mockup yet",
open feedback, Choose, Remove. Cards need less width, so the grid floor dropped
470px → 360px. One iframe per option left the board — the page-weight cost
flagged when tiles landed.

Live at v0.11.0, 136 tests green, tsc + vite clean.

## 2026-09-01 — Removing an option (v0.9.1), and two bugs it exposed

Adi asked the ordinary question — "how do I remove an option if someone
accidentally adds a third?" — and there was no UI for it. Building the control
turned up two things that would have failed in production.

1. **Deleting a chosen option left a dangling direction.** `db.delete(option)`
   did not touch `screens.selected_option_id`, so the board went on reporting a
   locked direction pointing at an option that no longer existed. Verified in
   the harness: the screen came back with `options: []` and
   `selected_option_id: 1`. On **Postgres** the `fk_screens_selected_option`
   constraint would have refused the delete instead — a 500 for the manager,
   not a friendly message. SQLite hid it, which is exactly the class of bug the
   sqlite-tests/postgres-prod split produces.
2. **Walkthrough steps and step approvals have no cascade declared** (unlike
   files and variants, which do). Deleting an option that carried a walkthrough
   would have hit the same FK wall in production. They are deleted explicitly in
   `svc.delete_option()`.

**The rule chosen for feedback, and it is a rule not a preference:** an option
carrying ANY pin cannot be removed — 409 `HAS_FEEDBACK`, naming the count. The
Interaction Standard says a conversation is never hard-deleted; the practical
reason is that a mis-click must not be able to destroy a client's feedback.
**Resolved notes protect it too** — resolved is not deleted, the conversation is
still the record. Screens inherit the rule because they cascade to options.

Worth keeping: the refusal is rendered to the manager exactly as the server
wrote it, `details` recovery line included. The backend already writes those to
be shown without translation; wrapping them in a generic "something went wrong"
throws away the only useful part.

**Also shipped:** `notes/variant-test/index.html` — a self-contained page wired
for two axes (brand quiet/hive, header A/B) and deliberately tall, so losing
scroll position on a toggle is obvious. The league concepts carry no variant CSS,
so without it there is nothing to see when testing ask 3.

Live at v0.9.1, 132 tests green, tsc + vite clean.

## 2026-09-01 — Ask 3 built: two options plus toggleable variants (v0.9.0)

rian's third M2 ask, and the mechanism decisions from `road-to-usable.md` §1a
are now code. Live at v0.9.0, 126 tests green, migration applied on Postgres at
boot, tsc + vite clean.

**A variant is a CSS class the bridge swaps on `<html>`.** One bundle serves
every combination; toggling costs **no reload**, so the client keeps their
scroll position while flipping. That is the entire reason this beats separate
HTML files, and it is why the bridge — not the server — owns the swap. The
handler only ever removes and adds classes the APP names (it is sent both the
full set and the active set), so a page's own classes survive and page content
can never influence which classes exist.

**The rule that must not be eroded: a variant choice is PART OF the locked
direction.** `screens.selected_variants` rides alongside `selected_option_id`,
and `screen_decided()` requires every axis of the selected option to be
answered with a value that exists. The rollup asks that function, so `done`
cannot fire on half a decision. `test_a_second_axis_must_also_be_answered_for_done`
is the guard.

**Three judgement calls worth keeping:**
- **Toggles are captured AT the moment of choosing**, not asked for separately.
  What the client is looking at IS what they are approving, so there is no
  second gate — and the confirm dialog names the whole thing ("The Direct
  Answer with Full hive"), because silently locking a brand level they never
  read is asking them to approve half an answer.
- **An untouched toggle still records a complete choice.** Axis defaults fill in
  on the server. A client who never touched anything chose what they were
  looking at; leaving the direction half-locked because they did not fiddle
  would be a bug that only shows up weeks later as a project stuck off `done`.
- **The notification names the variant** — "Direction picked: The Direct Answer
  (Full hive)". The half a bare option name drops is precisely the half we
  would have got wrong.

**Pins stay per-option**, as decided. A pin whose anchor drifts when a variant
changes the layout is a far smaller problem than a pin that vanishes — a client
who flips a toggle and sees their own feedback disappear concludes the tool lost
it.

**Authoring is inline, above the design** (manager only), not a settings screen:
a CSS class only means anything against the design in front of you, and the
fastest way to learn a class is wrong is to add it and watch the frame not
change. The concepts must carry the CSS — this is authored, not uploaded.

**Next: ask 4** — option 2 gets a smaller hero. Design work in `leaguelaw`, then
re-seed. Under the two-option model: option 1 = The Direct Answer (no hero),
option 2 = a reduced-height hero closer to the firm's existing site, with the
quiet/hive brand level as the variant toggle rather than as separate options.
That collapses Adi's six seeded concepts to two, which is the shape rian asked
for. **Then the human pass, which gates everything.**

## 2026-09-01 — Part-starring reverted; back inside rian's mandate (v0.8.2)

Adi's call, and the right one. Starring parts of a design (v0.7.0) was his idea
and mine — it is not among rian's four M2 asks (tool walkthrough · contents pane
· two options with variants · option 2's smaller hero). It came out before it
could reach darren and start producing feedback in a shape rian had not designed
for.

**The idea is not lost, and it was good:** the client stars parts they like as
they scroll; stars are a preference signal, never a gate; the board reads a
proportion rather than a count. If rian wants it, the design is in the 0.7.0
handoff entry and the changelog, and it rebuilds in an afternoon.

**Migration handling worth copying.** The CREATE migration (`c4a7e91b6d20`) was
KEPT and a DROP migration added on top, not deleted. Production had already
stamped that revision; deleting a migration the database has run leaves alembic
unable to locate its head and the container fails at boot. Schema changes here
are forward-only — reverting a feature still moves the chain forwards.

**What stayed, and why it is not drift:**
- v0.7.1 (the client can unpick a direction) — rian's own §0 list asks the
  question outright: *"no undo for the client — is that right?"* Answering it is
  mandate.
- v0.5.0's six fixes — from §0, which rian asked for explicitly and told us to
  let shape the backlog.
- v0.7.2 (chrome) and v0.8.x (the board) — Adi's own asks, on his own prototype.
  Not rian's, but the person who owns the milestone asked for them.

**Where we are now:** rian's asks 1 and 2 are done; **3 (two options with
toggleable variants) and 4 (option 2's smaller hero) are next**, in that order.
No more discretionary UI work until they land — and there is an efficiency
argument as well as an obedience one: ask 3 changes the option model, so
anything built on the current one gets redone.

Live at v0.8.2, 116 tests green (123 minus the 7 star tests), tsc + vite clean.

## 2026-09-01 — The board modelled on Adi's prototype (v0.8.1)

Adi pointed at prototype.demoing.info — his own WordPress Proto Preview — and
asked whether easel's overview could be like it, "wide because we want to
accommodate options". Read the source directly (`/srv/apps/prototype/wp-content/
plugins/proto-preview/`, we are in `prototype-dev`) rather than working from
description.

**What that overview actually is** — a full-page "Project Overview Matrix",
`max-w-[1920px]`, grid up to FIVE columns:
- sticky header: title + project switcher, stakeholder avatars, **three view
  modes (Matrix / Sitemap / List)**, search, "Launch prototype"
- progress banner: weighted completion % + bar, and **status filter chips**
  (Building Prototype · Waiting Approval · Building Page · Waiting Review · Done
  · Later) with counts
- card per screen: `#N` index, name, "N Options" pill, a **browser chrome bar**
  (`/slug • Opt 1`), a 16:10 thumbnail, hover "Zoom In & Review", a scrollable
  strip of option buttons with ✓ on the selected one, then "Open Screen ➔" and
  an optional "Dev Site" link.

**A useful confirmation:** its thumbnails force the iframe to a real
`width: 1440px` and scale by `cardWidth / 1440` in JS — the same conclusion
v0.8.0 reached independently for the min-width clipping. That approach is
correct; keep it.

**Shipped now:**
- **1920px board.** The root cause of "cramped" was `--app-maxw: 960px` applying
  to the project route. Options need width and the column was the reason they
  never had it.
- Browser-window framing on every thumbnail, hover "⤢ Open & review", `#N` index
  + slug on the card head, and search over screens AND option names (matching an
  option keeps its whole screen — the screen is the context that makes the hit
  legible).

**Deliberately NOT ported, and this is a scope boundary rather than a
preference:** the status pipeline and the "Dev Site" link. Those statuses span
BUILD phases — "building page in WordPress", "staging QA" — and rian's decision
in brief.md is explicit: easel is mockup presentation & feedback; `review` is
the separate future caddie app for build signoff; staging-URL review is review's
job. Porting them would quietly turn easel into review. Worth raising with rian
rather than deciding here, because the pull is real: the prototype's tracker is
genuinely useful, it just belongs to the next app.

There is a second reason to hesitate: easel's status is a PROJECT-level rollup
of six frozen states from the caddie tool contract, while the prototype's is
per-SCREEN. Adding per-screen status is a modelling change against a frozen
contract, not a UI addition.

**Kept easel's own improvement over the prototype:** the prototype shows one
thumbnail — of the active option — plus a text list. easel shows a tile per
option. Adi chose that shape earlier, and going wide is what makes it work; his
"wide because we want to accommodate options" reads as an argument FOR it, not
against.

**Still open from the prototype, in value order:** the List view (a spreadsheet
tracker — genuinely useful agency-side), stakeholder avatars, and the Sitemap
tree (lowest value here — easel reviews mockups, it does not own IA).

Live at v0.8.1, 123 tests green, tsc + vite clean.

## 2026-09-01 — The board rebuilt around "which of these?" (v0.8.0)

Adi asked to look at the overview page without a fixed complaint. Reading it
against the client's actual job turned up one thing that mattered more than the
styling.

**The board presenting three designs was showing one.** A screen card rendered a
single thumbnail — of the *leading* option — and listed the rest as text. So on
the league Homepage, two of the three concepts were invisible on the page whose
entire purpose is "which of these?". Each option now gets its own tile: its own
thumbnail, name, tag, ★ proportion, open-feedback count and Choose control.
Adi picked this shape over a lead-image-plus-filmstrip.

**Worth keeping straight against the earlier argument:** this is not a reversal
of "don't compare whole pages side by side". Thumbnails are for RECOGNISING a
design, not judging one — at tile size the three openings are distinguishable at
a glance, which is what a board owes. Reviewing at 34% scale is still wrong, and
still not what this does.

**A latent bug the change exposed.** `.scr-thumb-clip iframe` was sized as a
PERCENTAGE of its card (500% at `scale(0.2)`). That only cleared these concepts'
`min-width: 1380px` while the card stayed ~300px wide. One tile per option is
much smaller, and a 160px tile would have rendered the frame at 800px — the
design clipped down its left edge, silently. The frame now lays out at a real
1440px and scales by `calc(100cqw / 1440)` via a container query, which is
correct at any tile size. **Any future thumbnail must size from a real pixel
width, never a percentage of its container** — the min-width is the reason.

**The contents pane on the board was my redundancy, now fixed.** As a 15rem
sidebar it duplicated the grid exactly — its sections ARE the screens, its items
ARE the options — while taking width from the one thing that needed it. Adi
chose "keep it, collapsed": it is a strip above the grid carrying the overall
progress count, opening on demand. Unchanged in the viewer, where the grid is
not on screen and it genuinely navigates.

**Also:** a one-line lede for the client side, because the board never said what
it wanted from them. The tour says it once; this says it every visit, for
whoever skipped the tour or came back a week later.

**Left alone deliberately:** the status chip still shows to the client. It reads
in universal language as of v0.5.0, which was the fix for that — demoting it now
would undo a decision rather than build on it.

**For Adi to check, since I cannot read prod data:** the league project's
DESCRIPTION renders directly under the title for darren. If it still says
"seeded by Claude — curation pending Adi's review" it is internal text facing
the client. Also unverified: 4 lazy iframes per board (3 Homepage options + Your
Team) is more page weight than the 2 it used to load.

Live at v0.8.0, 123 tests green, tsc + vite clean.

## 2026-09-01 — The viewer's chrome halved (v0.7.2)

Adi: "i like to see the header more compact so not taking real estate."

**The real cost was two bars, not one tall one.** The viewer was rendering the
app topbar (brand + bell + account menu, ~45px) AND its own toolbar (~45px) —
~90px of chrome above a design that is the entire point of the screen. The
topbar is no longer rendered in the viewer; the bell and account menu move into
the viewer toolbar, ruled off on the right so they read as app chrome rather
than as another action on this design. Threaded as a `chrome` ReactNode prop
from App rather than duplicating the components.

**A quieter version of the same bug: the toolbar wrapped.** `.vtb` was
`flex-wrap: wrap` unconditionally, so every control added to it brought it
closer to silently becoming two rows — which is how a "compact" bar grows back.
It is `nowrap` on desktop now, with the project and screen names truncating.
Phone width still wraps (there genuinely is not enough room) and the mode toggle
takes its own row rather than squeezing.

Plus slimmer metrics: 44px min-height → 34px, tighter padding on the bar, its
buttons, and the mode toggle.

**Checked, because hiding a header is exactly how you lose one:** the View As
banner is a FIXED BOTTOM bar rendered outside the shell, so it is untouched — an
impersonation session still announces itself in the viewer. Getting home still
works: the breadcrumb goes to the project, which has the full topbar.

**Unverified without a browser:** the account menu's dropdown now opens from
inside a `position: sticky` bar rather than the topbar. Nothing in the ancestry
clips it, but that is exactly the sort of thing that only shows up on screen —
worth a glance on the next human pass.

Live at v0.7.2, 123 tests green, tsc + vite clean.

## 2026-09-01 — The client can unpick a direction (v0.7.1)

Adi: "need to do unchoose option there." This closes the open question the §0
pass raised and rian's plan flagged — *"'Choose this direction' is a button with
no confirmation and no undo for the client (only the team can clear a
selection) — is that right?"* Answer: no.

**What was wrong with team-only clearing.** The client could perform the
irreversible act but not reverse it, so the one person whose mind it is had to
ask permission to change it. Worse, it made "you can change your mind" — which
the confirm dialog and the tour both say — only half true: they could re-pick a
different option but never step back to undecided.

**The affordance is the ★ chosen marker itself**, in the viewer breadcrumb and
on the board. It was a passive badge; it is now the undo, and it opens the same
confirm dialog in unchoose mode.

**One component for both directions, deliberately.** What we promise about
committing has to stay consistent with what we promise about reversing, or the
client learns the two from different places and trusts neither. The choose copy
changed as a consequence — it used to read "clearing the choice altogether is
something we do for you — just ask", which is now false.

**The unpick notification is TIMESTAMPED, not keyed on the screen.** Select
dedupes on `select:{screen}:{option}` so re-picking the same thing does not
re-notify — correct there. An unpick keyed the same way would silently swallow
the SECOND withdrawal in a pick/unpick/pick/unpick cycle, and the failure mode
is us building on a direction that had been taken back. There is a test for the
full cycle.

The rollup needed no change: clearing drops `selection_complete`, so the project
falls out of `done` back to `waiting_client` on its own.

Live at v0.7.1, 123 tests green, tsc + vite clean.

## 2026-09-01 — "I like this bit": stars on the parts of a design (v0.7.0)

Adi's idea, scoped down by him for cost: not the cross-option comparison matrix
we brainstormed, but one option at a time as now, with the client starring parts
they like as they scroll and seeing a tally at the end.

**The cost decision that made it cheap: stars anchor to WALKTHROUGH BEATS.** A
beat is already a named, selector-targeted part of the design authored by the
designer ("Your number, in your face" → `.phone`). So there is no `data-region`
convention, no authoring pass over the league concepts, and no new "region"
vocabulary — the parts already exist and are already measured by the bridge for
the spotlight. If parts ever need to exist independently of the presentation,
`step_stars` grows a nullable region key rather than being replaced.

**The rule that must not be eroded: a star is a PREFERENCE, never a gate.**
`step_stars` is deliberately a separate table from `step_approvals`. Approvals
feed the status rollup and can close a project; stars feed nothing but the
tally. `test_a_star_never_closes_a_project` exists precisely so a later session
cannot "simplify" the two into one row. Enthusiasm must not be able to mark work
done.

**Other calls worth keeping:**
- **Play mode only.** In comment mode a click on the design is a pin; two
  gestures on the same surface must never be ambiguous.
- **Silent — no notification per star.** A client starring their way down a page
  would fire a dozen alerts at us. The signal is worth reading in aggregate.
- **The board shows a PROPORTION (★ 3/5), not a raw count.** Options carry
  different numbers of beats — the designer notes put the full walkthrough on the
  entry option and only differences on the rest — so raw counts would rank
  options by how much was written about them rather than by what landed. This is
  the single easiest thing to get wrong here.
- **Our own stars don't rate our own work** (client-side only), mirroring
  approvals.
- **The tally is not a leaderboard.** It shows what they starred and then offers
  the direction choice while it is fresh. Stars inform the decision; they never
  compute it. This is what keeps the feature from becoming a way to avoid
  deciding — which was the main risk when we talked it through.

**Deliberately NOT built** (Adi cut it on cost, and the reasoning should
survive): the cross-option region matrix. Whole pages cannot sit side by side —
these concepts carry `min-width: 1380px`, so three-up is 4,140px rendered at 34%
scale, and 16px body text lands at ~5px. Measured too: the seeded homepages are
**94% identical**, differences clustered in three regions with the main one 7%
down the page, so a synchronised three-up scroll would spend most of its time
comparing identical content. If simultaneous comparison is ever wanted, compare
ONE region across options at readable size — not whole pages.

**Still unbuilt and still the better answer to "short attention span"**:
preserving scroll position when switching options, so flipping is an A/B
flicker at full size. Adi's own designer note (pin H2) already specifies it —
"use the arrows at the top to flip straight back to the quiet twin — same scroll
position" — and it is not implemented. Cheapest high-value item on the list.

**Needs rian's nod.** This is new capability beyond his four M2 asks and it
touches his two-options-plus-variants decision: stars are the unconstrained
version of the same instinct variants serve in a designer-sanctioned way. The
framing to put to him is additive — "variants as planned, plus stars as the
feedback layer; the stars tell us which axes should become variants next time."

Live at v0.7.0, 122 tests green, migration applied on Postgres at boot, tsc +
vite clean. Unchecked in a browser: marker placement against real measured
rects, and whether the markers crowd a page with many beats.

## 2026-09-01 — Direction-picking wears a star (v0.6.2)

Adi: "can we have Star icon there before text Choose this direction, it seems
human to star something." Right instinct, and it settles a metaphor the control
was previously confused about.

**☆ while it is an offer, ★ once it is the choice** — applied to every surface
the act appears on, not just the button he named: the viewer's "Choose this
direction", the overview's per-option control, the breadcrumb's chosen marker,
the contents pane badge, and the confirm dialog.

**The tick is gone from this control.** It used to read "☆ Choose" → "✓ Chosen",
which is two metaphors in one story: a star is an act of favouriting, a tick is
"task complete". Choosing a design is the former — the client is saying *this is
the one*, not *I finished a chore*. Both states now tell the same story.

Glyphs are decorative (`aria-hidden`); every control keeps its text label, so
nothing depends on the icon rendering.

Live at v0.6.2, 115 tests green, tsc + vite clean.

## 2026-09-01 — Adi's human pass on the arrival experience (v0.6.1)

First real browser feedback on the tour. Two defects and one design note — and
the two things he confirmed WORKING are worth recording so nobody "improves"
them later.

**Confirmed good, leave alone:** bubble placement and overlay for a small,
on-screen target ("position and overlay correctly"), and the standout treatment
of the "Choose this direction" button.

1. **The bubble scrolled away and got lost.** On a long mockup he scrolled down,
   lost the guidance entirely, and only found it again by scrolling back up.
   Cause: the bubble was positioned purely from the target's `getBoundingClient
   Rect()`, which is in VIEWPORT coordinates — so as the page scrolled, the
   bubble tracked its target straight off the top of the screen. Fix keeps the
   good case intact: while the target is on screen nothing changed; the moment
   it leaves the viewport the bubble **docks to the bottom** and grows a **Show
   me** button that scrolls back to the spot it is describing. A tour you have
   to go looking for is worse than no tour.

2. **The mode toggle was competing with the CTA — and this was a real design
   bug, not a tour bug.** `.vtb-mode button.is-on` was filled with
   `var(--brand-accent)`, the *identical* colour as `.btn-primary` on "Choose
   this direction". Two elements wearing one colour claim one meaning, and it
   showed up worst exactly when the tour spotlit the toggle — pointing the
   client at something that looks like the primary action, one beat before
   asking them for the primary action. The mode toggle now uses a neutral
   selected state. **Rule worth keeping: `--brand-accent` belongs to
   direction-picking and nothing else** — that click is the output the whole
   tool exists to produce.

3. The spotlight ring moved from a hardcoded `#f6b73c` to `var(--amber)` for the
   same reason — the ring must read "look here", never "press this".

Live at v0.6.1, 115 tests green, tsc + vite clean. Still unchecked by anyone:
phone width, and whether four beats is the right length.

## 2026-09-01 — The arrival experience: tool tour + contents pane (v0.6.0)

M2 asks 1 and 2 plus finding 7, built as ONE thing rather than three features —
adi's framing ("land on an overview onboarding first then go to specific page
guided by Designer Note") is the same shape rian described, and the pieces only
make sense together. Live at v0.6.0, 115 tests green, migration applied on
Postgres at boot, tsc + vite clean. **Not yet checked in a browser — adi is
doing the human pass.**

**The flow.** Land on the board → a welcome panel with a way straight past it →
"Show me around" navigates into the first READY option with `?tour=1` → four
coach-mark beats over a real design → ends on "Choose this direction". The tour
runs in the viewer, not on the board, because its beats need something behind
them: pointing at the mode toggle with nothing to switch between explains
nothing.

**Where each rule came from** (worth keeping straight — rian left constraints,
not a design):

- *Not a wall.* Skippable from the welcome panel, Escape exits any beat, and
  every exit — finish OR dismiss — records the version. A person who said "not
  now" is not asked again next visit.
- *Once per person per board.* `tour_progress(instance_id, username,
  seen_version, completed)`. Per board, so a client added to a second project is
  oriented there rather than assumed fluent.
- *Versioned re-teach.* `TOOL_TOUR_VERSION` in `app/tour.py`; everyone below it
  is re-offered. Straight from the hartlingowners precedent rian named.
- *Steps as data.* `frontend/src/lib/toolTour.ts`. A step offers SEVERAL targets
  and the first actually laid out wins (survives the phone-width collapse); a
  step whose target vanished is skipped, never shown pointing at nothing. Also
  from that precedent, via the teardown in inputs-2026-08-28.md §2.
- *Content.* Adi's designer-note pins 2 ("The notes have an off switch") and 3
  ("Why is everything blurry?") moved here VERBATIM — they were tool
  explanations smuggled into a design tour — plus commenting and the closing
  beat, which is his Z pin ("Two decisions, that's all"). Four beats, as asked.

**The decision rian left open** — new target type vs separate table — resolved
as NEITHER. The tool tour has no `option_id`: it is app-owned content, identical
on every board, written rather than authored. Putting it in `walkthrough_steps`
would have meant a nullable option_id and a discriminator on a table whose every
other row belongs to an option, plus a migration every time a sentence changes.
Only the PROGRESS is server state. If a future need arises for per-board custom
tool tours, that is when a table earns its place.

**Contents pane.** Sections are "How to use this" then one per screen; entries
are the options. That grouping falls out of the structure already there — and
out of the designer notes' own rule ("the walkthrough lives once, on the entry
option; every other option carries only what changed") — so no tours table was
invented. Two deliberate calls:
- *Done is answered from the asker's chair.* A client sees what THEY have been
  through (their own approvals); a manager sees what the CLIENT has been through.
  Each is the question that person actually has.
- *An entry with nothing to approve is never "done"* and is not counted in the
  section total. It is a place to go, not a beat to complete; counting it would
  make the progress read a lie.
- In the viewer the pane is an OVERLAY, not a sidebar: a sidebar resizes the
  stage, and the stage's width is what the mockup's scale-to-fit is measured
  from. Shrinking it would re-scale the design every time the pane opened.

**Also settled, on adi's own authority.** His closing pin Z reads "Pick an
opening ... AND a brand level ... tick your choice in the option list" — two
decisions. Under rian's two-options-plus-variants collapse that is exactly
option + variant, so **a variant choice IS part of the locked direction**. That
answers one of the three model questions ask 3 was blocked on: the selection
needs a `selected_variants` alongside `selected_option_id`, and rollup's `done`
must account for it. Not built yet.

**Two consequences of the collapse, for whoever does ask 3.** The designer
notes' 39-pin / 6-option math is void: Set C (brand intensity, H1/H2) becomes a
variant-toggle note and Set N ("you are here") largely disappears, since two
options plus a toggle leave no long chain to orient in. The doc's own open items
— option order and client-facing names — are answered by the same collapse.

**Left for the human pass (adi).** Everything visual: whether the spotlight
lands on the right chrome, whether the bubble placement works at phone width,
whether four beats is the right length, and whether the welcome panel reads as
an invitation rather than an obstacle. The tour targets easel's own DOM through
`data-tour` attributes — if a beat lands wrong, the fix is a selector in
`toolTour.ts`, not code.

## 2026-09-01 — Findings 2-6 built and shipped (v0.5.0), on adi's direction

Adi ruled on all seven §0 findings. Six became work; one was dismissed. Live at
easel.bowden.works v0.5.0, 103 tests green (62 conformance + 41 easel), tsc and
vite build clean.

**Finding 1 (pre-send visibility) — accepted as-is, no change.** Adi: rian added
darren but has not told him, and this is the first test, so the grant-implies-
visibility behaviour is fine for now. Worth revisiting before a real client
engagement, where "Present to client" reading as a gate it is not could matter.

**What changed, and the reasoning that is easy to lose:**

1. **Fileless options are client-invisible** (finding 2, per adi: "if we have
   fileless then it should not be shown to client, for admin we can do this
   while eg in process of creating the mockup"). `visible_options()` in the
   projects service is the single definition; the project view filters, and
   `/view`, `/pins`, `/walkthrough` 404 for a client by id so hiding is not
   cosmetic. A screen with nothing ready vanishes for the client too. Managers
   keep everything — that is where the upload control lives.

2. **D6 corrected** (finding 3; adi asked for my suggested reading).
   `needs_attention` used to mean "selection complete + open client pins =
   conflicting signals" — which is what a *good* round produces, so the most
   alarming state was the terminal state of success. Now: open client feedback
   is `waiting_agency` (we owe a reply, whatever else is true), and
   `needs_attention` fires only on the true anomaly — **presented with nothing
   on the board to review**. I first wrote it as "any screen with no ready
   options", then tightened it: adding an About screen ahead of its upload is
   ordinary staging and the client never sees it, so it must not flag.
   The rollup now counts READY options (those with a mockup), which is the same
   definition the client-visibility rule uses — one concept, not two.

3. **One status vocabulary for both sides** (finding 4, per adi: "we need
   universal language in here so agency like us and client see it as one
   meaning"). The chip renders to clients, and read **"Needs our reply"** — our
   sentence about ourselves, shown to them. Labels now describe the work, not
   whose turn it is from a point of view: In preparation · Awaiting review ·
   Feedback to address · Direction locked. **Rule for anyone adding a state:
   write the label so it is true read from either chair.**

4. **No more mid-review reload** (finding 5). The viewer pins the first bundle
   URL for the life of the mount — nothing may change a live iframe's src,
   because that is a reload and a reload loses the client's place. Pins now poll
   on 20s so a reply appears while they are reading, which is the half of
   "updates should arrive as available" that actually matters; the frame is
   untouched by it.
   **Security-relevant, flagging deliberately:** the bundle token TTL went
   15m → 60m. With the src pinned, the token has to outlive a review sitting or
   lazy-loaded images start 403ing mid-scroll. None of the three protected
   mechanisms moved — CSP `sandbox allow-scripts` on every response, the
   `allow-scripts`-only iframe, and path-token auth are all unchanged; only the
   credential's lifetime widened. **`srv-gw security-audit` is owner-only, so I
   could not run the post-change audit — rian should.**

5. **Direction-picking is prompted** (finding 6, per adi: "should not be silent,
   should be prompted in a way that is easy in early client onboarding"). A
   dialog states what choosing means and what happens on a change of mind
   (re-pick freely; clearing stays a team act). Deliberately worded as the same
   sentence the tool walkthrough will teach, so the moment is never the first
   time the client meets the idea.

6. **Bug found while wiring it:** the viewer identified the current option **by
   title** — both for ‹ › navigation and for "Choose this direction", which took
   `options[current].id`. Two options sharing a title would have chosen the
   WRONG one. By id now. This was latent and about to become likely: the
   two-options-plus-variants model makes repeated titles the normal case.

**Test posture.** Existing tests were updated to express the new contract, not
to go green: `add_screen_option` now uploads a mockup, because an option a
client can review is one with a mockup. New tests cover the visibility boundary
(including the by-id 404s), the empty-board anomaly, staging screens NOT
flagging, and an end-to-end happy path asserting it lands on `done` via
`waiting_agency` — never on the alarming state.

**Next (finding 7 + asks 1-2), not started.** Adi wants the client to land on an
overview onboarding first, then be guided into a specific page by the Designer
Note. That merges finding 7 with ask 1 (tool walkthrough on arrival) and ask 2
(sidebar contents) into one arrival experience rather than three features. The
three variant model decisions in the plan are still open and gate ask 3.

## 2026-09-01 — M2 §0: the client pass, done functionally (7 findings)

Ran the full client journey against the app in-process (the conformance
harness's `as_user`, a real board: screens → options → bundle upload →
walkthrough → send → pin → reply → resolve → direction). No browser, so this is
the *functional* half of §0 — every finding below is reproducible without one.
The visual half (phone width, does the spotlight land, does the page read)
still needs a browser and is NOT covered here.

**What is solid and should not be re-litigated.** The permission boundary is
airtight: every manager-side mutation a client attempted (add screen, upload,
author a step, delete an option, edit the description, send) returned 403 with a
clean `error_code`, and an ungranted project returns **404, not 403** — so ids
aren't confirmable. Mockup isolation behaves exactly as designed under test:
`CSP: sandbox allow-scripts` on every `/m/` response, bridge injected at serve
time, relative subresources resolving under the same token prefix, and a
top-level navigation refused with the friendly page. Don't spend M2 time there.

### Findings

1. **The client can see and act on a board before "Present to client" — HIGH.**
   The grant is the access; `sent_at` only feeds the rollup. Before send, darren
   listed the project, opened it, read every option, left a pin, **and picked a
   direction** — all 200. This directly undercuts the thing rian reserved for
   himself: `league` is deliberately UNSENT so *he* controls the moment, but
   darren has been granted since 2026-09-01, so if he signed in today he would
   see the concepts. Either the grant must not imply visibility until sent, or
   "Present to client" has to stop being described as the gate. **Decide this
   before darren gets the link** — it is the one finding that is live right now.

2. **An option with no mockup renders a broken frame, not an empty state — MED.**
   `GET /api/options/{id}/view` happily mints a token for an option with zero
   files and returns `entry_url` pointing at a nonexistent `index.html`. The
   viewer's own empty state (`{!view && "No mockup uploaded yet."}`,
   Viewer.tsx:273) is therefore **dead code in exactly the case it was written
   for** — `view` is truthy, so the client gets a blank/404 iframe. The data to
   fix it is already in hand: gate on `option.has_files`, which the same
   component already uses on line 230.

3. **`needs_attention` is the terminal state of the happy path — MED-HIGH.**
   D6 defines it as "selection complete AND open client pins = conflicting
   signals." But the normal, desired end of a round is *exactly* that: the
   client picks a direction and leaves comments. My clean run finished at
   `needs_attention` — the most alarming chip in the set — having done
   everything right. The rollup's flagship state fires on success. Worth
   re-reading D6 against real journeys rather than patching the label.

4. **The client sees agency-internal status vocabulary — MED.**
   `StateChip` renders for everyone. A client's own project reads **"Needs our
   reply"** (literally the agency's *our*), "Waiting on client", or "Needs
   attention". This is the first thing on the first screen. The six states are a
   team instrument; the client needs at most "New" / "Reviewed" or nothing.

5. **The mockup reloads every 10 minutes and loses the client's place — MED.**
   `useViewToken` sets `refetchInterval: 10 * 60 * 1000` against a 15-minute
   token, and the iframe `src` is bound straight to `view.entry_url`. A new
   token is a new URL, so the frame reloads and the scroll position resets. On a
   4,600px homepage a client reviewing for half an hour is thrown back to the
   top twice. `mockups.py:32` says "the viewer re-mints transparently" — it
   re-mints, but not transparently. Re-mint into a ref and only swap the src
   when the current one has actually expired.

6. **Direction-picking is a silent, un-undoable, notify-every-time act — MED.**
   Confirmed the plan's suspicion. No confirmation step; the client can re-pick
   freely but **cannot clear** (team-only, by design); and every re-pick fires a
   fresh "Direction picked: X" to the team, so a client comparing two options by
   toggling generates a notification each time. Given ask 3 makes variants
   toggleable, this gets worse before it gets better.

7. **A one-project client still lands on a list page — LOW.**
   `ProjectsList`'s own comment says the goal is "they click it and are
   reviewing within seconds", but a client with exactly one project still lands
   on a grid of one card. Natural fold-in with ask 1's arrival experience.

### How this reorders M2

Finding 1 is the only one that is live for darren today and it is a decision,
not a build — it goes to rian. Findings 2, 4 and 5 are small, contained fixes
that make the client's first ten minutes materially better, and they are worth
shipping *before* the four asks rather than after. Finding 3 is a design
question that ask 3 (variants) will force open anyway — a variant choice
changing `done` is the same conversation. Finding 6 folds into ask 1's
"how to pick a direction" beat.

### Still blocked on rian (small)

`easel-qa-client` exists, but its 48h set-password link is in
`.qa-invite.txt` at **0600 rian-only** — a developer session can't read it, and
the app's invite path emails rather than returning a link, so there is no
self-serve route. One `chmod`, or paste the link, and the write-path client
testing opens up. The 48h clock is running from 2026-09-01 18:19.

## 2026-09-02 — Direction switching fixed; the page → direction navigator (v0.34.0)

rian found that switching to the other direction did nothing until a browser
refresh. Cause: the viewer pins the iframe's URL to the one it was born with
(so a token refresh does not reload the frame mid-review) — but that pinned
it across OPTIONS too, so the previous mockup stayed on screen. The birth URL
is per option now (`frameUrlRef` keyed by option id). The queries (pins,
steps, token) were already switching; only the document was not.

rian also did not love the switcher (a back-crumb and two chevrons around the
option name) and asked for a dropdown that makes it obvious there are several
concepts, and that scales to several pages. Built on a new pack piece:

- **`caddie-ui/Menu` + `CountChip`** — a grouped dropdown: trigger with a
  count chip, groups with a right-aligned note, items with meta + badges, the
  current one checked, an action footer.
- **`components/OptionNavigator.tsx`** — the trigger reads "Homepage · The
  Direct Answer · No hero with grid · 1 of 2"; the menu lists every page as a
  group ("2 directions" / "draft") with its directions beneath (concept tag,
  "N open", "★ chosen", "no mockup yet" disabled), plus "← Back to the board".
  Clients never see drafts. `data-tour="option-nav"` moved onto the trigger so
  the tool tour still lands.

Retired: the chevron switcher and its CSS.

## 2026-09-02 — BW-auth kit refreshed: the new View As pill (v0.33.1)

rian asked for View As on the newest BW-auth standard. The canonical pack had
moved (UI-STANDARD.md "View-As UX"): the un-hideable banner is now a compact
DRAGGABLE PILL — bottom-left, amber read-only / red acting, white halo, Exit
as its only control, never dismissable, position remembered per browser —
instead of a page-wide bottom bar. Refreshed by re-running the scaffolder
(byte-for-byte: `ViewAsBanner.tsx`, `bw-admin.css`, and the server-rendered
fallback in `bw_view_as.py`); nothing app-side needed changing. 213 tests
green. Reminder of the rule: kit files are never hand-edited — a re-run IS
the update path, and it also refreshed the conformance pack.

Also committed this session: Adi's pin-variants feature (a note remembers
the variation it was left on), and the notes/ ignore rule fixed so the
concept HTML is tracked while the img/ copies are not.

## 2026-09-02 — The walkthrough moves into the side panel as a timeline (v0.33.0)

rian's ask: when in a walkthrough, use the sidebar and show a timeline of
where they are. Done, on the pack:

- **`caddie-ui/Timeline`** (new pack piece): a vertical rail of every beat —
  done ✓, current (expanded in place), ahead — every node jumps.
- **`components/WalkthroughPanel.tsx`**: the tour as panel content. The
  current beat's words + approval control sit inside the rail; Back / Next /
  "Next: {option}" / "Ask a question" pinned in the footer; the manager
  editor (title, body, selector + Point, demo click + hold, approval flag,
  delete) replaces the timeline while open. The floating `TourCard` is gone.
- **Ask-and-resume**: asking a question from a beat pauses the tour, opens
  the composer at the beat's spot, and resumes at the same beat (play mode)
  when the note is posted or abandoned.
- **Two shell fixes found by looking**: the panel now sits BELOW the global
  row (it had been covering the bell + account menu), and the app area (app
  bar, variant strip, stage) reflows beside an open panel via
  `cu-panel-push` — the stage rescales, so a spotlight on the right of the
  design is never under the panel. Phones keep the overlay.

Proposal doc updated with the Timeline row and the two rules.

## 2026-09-02 — Caddie-alignment pass: the `caddie-ui` pack, two-row shell, discussion in the side panel (v0.32.x)

rian reviewed easel against caddie and asked for standards that work across
every future drop-in, not one app. What landed:

**A vendored UI pack — `main/frontend/src/caddie-ui/`** (same discipline as
`bw-admin/`: full copy, `--cu-*` tokens with fallbacks, token-root selector,
README = the standard, never hand-edited in an app):
- `GlobalBar` + `AppBar` — the two-row shell. Row 1 is the platform's (brand,
  locator, bell, account menu); row 2 is the app's (modes, option switcher,
  primary action). The caddie host bar, when it exists, renders into the
  locator slot. Adi had merged the account chrome into the viewer toolbar to
  save 40px; rian ruled the global row stays — every caddie app gets the same
  top edge.
- `SidePanel` — one Gutenberg-style frame (eyebrow/title, tabs, scrolling
  body, pinned footer, Escape). easel's tabs: Discussion · Contents · Settings.
- `Bell` — easel's 34px ring + review's panel format (sentence rows, "on
  context", category chip with dot, preview, time, unread tint). The app
  polls; the pack renders.
- `ThreadCard` / `ThreadView` / `Composer` — review's notes-sheet, simpler.

**What changed in easel:** the floating pin balloons and the old drawer are
gone. Pins stay on the design as numbered ANCHORS; the conversation lives in
the panel. Click the design → a marker drops and the panel opens on the
composer; click a pin → its thread. Contents is a panel tab, not an overlay;
the variant author moved to the Settings tab. `?drawer=1` still opens the
discussion (Adi's copy-link). Old `bell`/`drawer`/`pin-card`/`thread-*` CSS
retired.

**For the caddie session:** `.logs/planning/caddie-ui-standard-proposal.md`
— adopt the pack as canonical, amend 04 §6 / 02 §6 / 05 §3 as listed, add a
`decision` notification kind (a picked direction currently reads as "needs
you"). Pointer appended to caddie's runbook §5.

**agents.md** now carries the standing rule: every feature checks against
caddie or makes a written case for changing it; shared chrome comes from the
pack.

**Verified live in the browser** (rian's ask: look, don't just build): global
row + app row at 76px total; bell panel; discussion list, thread drill-in,
click-to-note. Compared side by side with review.bowden.works and
caddie.bowden.works.

**Deliberately not done / next:** board-level screen conversations still use
their inline form (candidate: a panel tab on the board); punchlist/caddie not
migrated to the pack (caddie's call); composer attachments not in the pack
yet. Feature change to flag: floating balloons removed — a significant UI
change, made because rian asked for review's sidebar model; anchors survive.

## 2026-09-02 — Mockup right-side padding fixed, plus a stale-deploy bug (v0.27.2)

rian reported extra padding on the right of the HTML mockups. Two defects.

**The padding.** `.stage` filled its container (`max-width: 1600px`) while the
mockup rendered at its own natural width (1410px) with scale capped at 1 — the
190px difference hung off the right edge. The stage is now sized to the
rendered mockup (`naturalW * scale`) and centred. Note for anyone touching
this: measure the **wrapper**, not the stage, or the observer is circular —
the stage would settle at whatever width it already had.

**It was hiding a real bug.** Pin and spotlight coordinates are percentages of
the mockup DOCUMENT but are positioned against the STAGE box. While those two
widths differed, every pin and every walkthrough highlight sat proportionally
off — worse the wider the screen. On a 1758px viewport the seeded pin was
~190px right of its element. Same fix corrects both, and the pin visibly moved
back onto its target.

**Second bug, found while verifying the first.** The SPA shell sent no
`Cache-Control` at all — only an ETag — so browsers cached index.html
heuristically and kept loading the OLD content-hashed bundle. The deploy was
correct, `/api/meta` reported the new version, and the UI was still running old
code; the fix only appeared with a cache-busting URL. The shell now sends
`no-cache` (revalidate — the ETag makes it a cheap 304) and hashed assets send
`immutable`. Regression test in `test_domain.py`.

**Worth internalising:** "deployed and /api/meta shows the new version" was NOT
sufficient evidence the running UI was new. If a change seems not to have
landed, check which asset hash the browser actually loaded before re-debugging
the code.

199 tests green. Verified live at 1758px: stage width == mockup width, gaps
167/166, no horizontal overflow, and a plain reload now picks up new builds.

## 2026-09-01 — Adi's four blockers: three resolved, one corrected

Adi's session flagged four things needing rian. Checked each; acted where it
was mine to act.

1. **QA client account — DONE.** `easel-qa-client` created (external, active,
   granted on easel.bowden.works). No email was sent (`--no-email`); the 48h
   set-password link is in `/srv/apps/easel/.qa-invite.txt` (0600, rian only)
   for rian to hand over. Adi signs in as it in a second browser profile and
   gets the real client experience including writing — View As alone is
   read-only for non-owners.
2. **docker exec — real, but narrower than stated.** `srv-gw exec` does not
   exist (confirmed) and `srv-gw db-query` cannot reach easel (it targets the
   shared MariaDB; easel is Postgres with no `db_name` in `.project.json`).
   But this only blocks re-seeding the `league` board. Adi can create his own
   test project, upload bundles with the folder control, author walkthroughs,
   and grant the QA account — all in the UI as an admin. He should test on his
   own board anyway, so test pins stay off the board rian will present.
3. **A browser — Adi's to fix, not rian's.** The Chrome extension connects to
   his own browser; he has a BW account and a site grant, so nothing is gated.
   The point behind the flag stands and is worth repeating: last session's
   live review found three defects in minutes that 94 green tests had missed.
   The visual pass is not optional on this milestone.
4. **Version control — real, bigger than "easel needs a repo".** NO project on
   this server is under git (checked easel, punchlist, caddie, scout,
   hartlingowners, bw-plugins) and the standards library says nothing about
   it. `/srv/apps` has no backup either — the only backup jobs are
   bw-plugins' own script and a hosting pg_dump timer. So the exposure is
   fleet-wide, not an easel oversight.
   **Done for easel:** `git init` at the workspace root, baseline commit at
   v0.4.2, `.gitignore` excluding secrets, `data/`, build products and the
   154MB reference video (which would otherwise be permanent history). Repo
   is group-writable so Adi can commit. No remote — this is local history,
   not a backup.
   **Still rian's call:** whether git becomes a convention for new-standard
   apps, and whether `/srv/apps` gets a backup. Both belong in the evolution
   plan / ideas.md rather than in one project's handoff.

## 2026-09-01 — Handover to Adi: M2 briefed (onboarding, contents, variants)

**Adi owns easel from here.** rian's brief is written up in full as
`.logs/planning/next-milestone-onboarding-and-variants.md` — read that first,
then `brief.md` + `agents.md`. Four asks, in rian's words:

1. **A tool walkthrough on first arrival** — how to comment, why photos are
   blurry, Play vs Comment, how to pick a direction. Distinct from the design
   walkthrough that exists; same engine, different content. Adi's own pins 2
   and 3 belong here rather than in the design tour.
2. **A sidebar table of contents** with sections — the tool walkthrough plus
   logical groupings of tours. Implies naming and grouping tours, which the
   model does not do yet (one implicit tour per option).
3. **Two design options, with toggleable variants inside them** (hexagon
   decorations, header configs) rather than an option per combination. This
   collapses Adi's six-option plan to two and makes every comparison
   one-variable — which is what his own notes said the six-option ordering
   got wrong.
4. **Option 2 gets a smaller hero**, closer to the firm's existing site.
   Design work in leaguelaw, then re-seed.

**Before those four: a full run as darren** (plan §0). rian wants Adi to
walk the client journey cold, at desktop and phone width, and turn what he
finds into the M2 backlog. View As gives the view but is read-only for
non-owners — so either rian runs the write steps, or (better) he creates a
throwaway external client account Adi controls. One owner command; noted in
the plan.

The plan doc carries the constraints and the decisions M2 has to make
deliberately (how a variant is expressed in a bundle; whether a variant choice
is part of the locked direction — it affects the rollup's `done` state;
whether pins are per-option or per-option-per-variant).

**View As: already available to Adi, nothing to grant.** Verified today —
`adi` holds `admin` with `accounts.view_as` + `all_instances`, `darren` is a
valid target, rank 41 vs 0 so the no-escalation guard passes. Read-only for
non-owners by design (acting is owner-only), which is the right mode for
checking the client's experience.

**Still rian's to do:** press "Present to client" when he has told darren
(league is deliberately unsent, no mail has ever been sent from easel); decide
the mockup-serving subdomain in `/srv/.logs/ideas.md`.

**Known friction Adi will hit:** running management commands in the container
needs rian's `docker exec` until `srv-gw exec` exists (filed in ideas.md), and
the seed only fills gaps — re-seeding changed content means deleting the rows
first.

## 2026-09-01 — Three viewer fixes from rian's live review (v0.4.2)

rian opened the seeded league project and found two problems; a third turned up
next to them. All three were in the "render someone else's HTML" family, all
verified fixed in the browser.

1. **Missing images.** The seed stored only `index.html`, so every
   `src="img/..."` 404'd and the concepts rendered image-less. My earlier
   "self-contained?" check was truncated by a `head -12` and I recorded the
   wrong conclusion — the curation agent had actually flagged
   `self_contained=False`. Seed now walks each page's image references and
   stores the whole bundle (28 images per homepage, 10 for the team page);
   the overview gained an **Upload folder** control (webkitdirectory) so Adi
   can upload multi-file bundles. Regression test asserts subresources serve
   under the same token prefix.
2. **Walkthrough highlighted the wrong spot.** My seeded rects were
   hand-guessed percentages of the whole document — "7% tall" on a 4,600px
   page is a 320px band. Steps now target **CSS selectors**, measured live by
   the bridge (`target_selector` existed in the schema and was unused). Two
   sub-fixes: the bridge resolves the first *visible* match (`.rcall` matched
   six copies hidden in hover mega-menus; `.phone` is the real header number),
   and it walks hidden ancestors.
3. **Right edge clipped.** These concepts carry `min-width: 1380px`, so a
   stage-width frame cut off the nav and phone number. The viewer now renders
   at the mockup's natural width and scales to fit — which is also the honest
   way a desktop concept reads at phone width. The scale initially did nothing
   because a `[]`-deps effect attached the ResizeObserver while the component
   still showed its loading placeholder; it is a callback ref now.

Verified live at 1278px: header/phone fully visible, images loading, steps 1-3
spotlight `.nheader` / `.phone` / `.triage` exactly. 95 tests green,
conformance green, audit 0 critical. Seed re-run is idempotent; project
description corrected (it still claimed rects were provisional).

Unchanged: league is still UNSENT and no mail has been sent.

## 2026-09-01 — /goal executed: easel rebuilt as a caddie app, league seeded, v0.3.1 live

The autonomous rebuild ran end-to-end. Live at easel.bowden.works (v0.3.1),
94 tests green (62 conformance incl. the instance suite + 32 easel), 0
security-audit criticals, nothing new flagged.

**What exists now:** caddie stack (pg sidecar on the internal network,
SQLAlchemy 2 + Alembic-at-boot, kit store in managed mode, OpenAPI-generated
TS client + TanStack); the product — projects/screens/options, isolated
mockup serving (`/m/{token}` + CSP `sandbox allow-scripts` on every response,
server-injected nonce bridge), pins as Interaction-Standard-shaped threads,
comment/play modes, discussion drawer, guided walkthrough with per-step client
approvals + drag-to-author, the bell, six-state rollup (pure fn, becomes the
ct1 status body); caddie seams (`external_ref`, `caddie_links`, `status_body`)
— NOT registered with caddie (drop-ins are M2, per plan).

**Seeded:** project `league` — Homepage with Adi's three openings (Direct
Answer / Welcome Desk / Bold Welcome, quiet brand) + Your Team, walkthrough in
Adi's designer-notes voice (closing beat requires approval), darren = member
granted on league only, adi = admin/all-instances, UNSENT (rian presents from
the UI after telling darren — no email was sent to anyone).

**Key judgment calls** (full list in diary): path-embedded bundle tokens made
unconditional (stronger than the goal's fallback wording); managers ride
all_instances=True (kit semantics would otherwise hide every project);
3-not-6 seed options + 5-beat walkthrough per the goal's curation bounds, with
Adi's full 6-option/39-pin plan referenced in the project description;
walkthrough rects provisional; easel-qa account skipped (no browser
available, so the TestClient bar applied per goal §8).

**Owner actions remaining for rian:**
1. Sign in, open league, run the walkthrough, drop/resolve a pin, View As
   darren — the visual pass no browser was available for.
2. Click "Present to client" when darren has been told.
3. Decide the mockup-serving subdomain (ideas.md) — defense-in-depth, DNS+Caddy.
4. Optional: have Adi reorder/extend options per his notes (v1·v3·v5·v2·v4·v6
   + hive twins) and true up the walkthrough rects.

**Deferred to caddie M2:** vendoring caddie_tool.py/caddieHost.ts, tool
registration, bw_interaction swap (embryo is behind services/interaction.py;
tables are §4-shape-compatible — swap plan: replace that module's internals,
keep the router surface).

Framework feedback delivered: 6 learnings appended to caddie 05 §5, 3 items in
.logs/v8-feedback.md, 2 substrate asks in /srv/.logs/ideas.md.

## 2026-09-01 — /goal command authored: the autonomous rebuild prompt

rian settled the three open forks: **members only** (no anonymous commenting —
Adi's typed-name path dropped), **easel/review/scout stay separate** (easel =
mockup presentation & feedback; review = future caddie app for build signoff;
scout = research), **adapt Adi's design, don't clone it**.

Wrote `.claude/commands/goal.md` — a `/goal` slash command that drives a fully
autonomous orchestrated rebuild session: caddie stack bar, seams-now (drop-ins
are M2), mockup isolation via CSP `sandbox allow-scripts` ON THE RESPONSE (not
just the iframe attribute — direct navigation was the bypass), the
opaque-origin postMessage idiom, kit-default level names (the conformance pack
asserts `admin`/`member` exist — verified in test_levels.py:27), and the seed:
a league project curated from `/srv/apps/leaguelaw/prototyping/` (231 real
concept files), darren as member granted on that instance only, adi's admin
row preserved. Explicit no-email rule for darren; rian tells him himself.

The draft was adversarially reviewed by a 3-lens workflow (framework
conformance / security / operability) — 12 findings, all folded in. The two
blockers: the direct-navigation sandbox bypass, and the manager/client level
names that would have failed the pack's own gate.

Next: rian (or an autonomous session) runs `/goal` from this workspace.

## 2026-09-01 — Scoped the caddie rebuild (research only, no code)

rian asked what it would take to rebuild Adi's WordPress prototype as easel on
the caddie standards. Written up in `.logs/planning/caddie-rebuild-scope.md`.

Three things that change the picture:

1. **Both caddie drop-ins are unbuilt.** Verified — no `caddie_tool.py`,
   `caddieHost.ts`, or `bw_interaction` anywhere on the server. Punchlist (the
   first tool) is running seams-only for the same reason. So the path is
   seams-now, drop-ins-later; phases 1-5 are unblocked, phase 6 waits on
   caddie M2.
2. **Adi's plugin serves mockups same-origin as the app** — arbitrary
   scripted HTML running with the app's cookies. easel must serve prototypes
   from a separate origin + sandbox the iframe + postMessage. Scope doc §4.
   This is the strongest argument for rebuilding rather than hosting his
   plugin.
3. **Adi already built the walkthrough** (`pp_story_steps` + `tour-engine.js`,
   868 lines) *with per-step approvals* — he converged on rian's
   hartlingowners idea independently and went further. The no-login path is
   also already built (`user_id DEFAULT 0` + author_name/email).

Open, needs rian: the anonymous-commenter model (it collides with both
standards - proposed resolution in §5), and whether easel eventually absorbs
the legacy `review` app (§6). Both decide schema, so they come before code.

easel's current stack does NOT meet the caddie bar - SQLite, no ORM, no
migrations, hand-written TS client. Phase 1 is a conversion.

## 2026-09-01 — adi granted access; app level was already admin

`srv-gw grant-access --project easel --user adi`. One command covered both
layers — it added him to `easel-dev` (+ a user ACL, effective immediately, no
`newgrp`) **and** granted the BW account `easel.bowden.works`. A separate
`id-user-grant` is not needed here.

Verified: `easel-dev` = rian, podman-dev, srv-gateway, adi · `.project.json`
members = [rian, adi] · ACL `user:adi:rwx` + default · BW site grant present ·
container `Up (healthy)` on `172.17.0.1:3153` · host returns 200 and is **not**
gated (correct — Pattern B).

**No app-level change was needed:** `bwa_members` already had `adi = admin`,
added by rian 2026-08-28 14:07 during the build session. rian is the OWNER and
is deliberately *not* a member row — he's a code-level super admin
(`bw_accounts.py:13`).

Note for later: the D-seed gives `admin` an `assignable` list of `["member"]`
only, so adi can add people but can't make anyone else an admin. If a second
admin is ever needed, either rian assigns it, or the level's `assignable` is
widened in `bw-app.toml` + a scaffolder re-run (don't hand-edit `bw_config.py`).

Also worth knowing: `/app-authorize` gates on "is a real BW account", not on the
site-access list — so what actually keeps non-members out of easel is the app's
own default-deny (`main/tests/test_default_deny.py`), not the BW grant.

`srv-gw security-audit` after the grant: 0 critical, nothing easel- or
adi-related.

## 2026-08-28 — Intro context captured: what easel is for, and the three inputs

rian handed over the framing rather than a build request. easel is the
**InVision replacement** — send mockups to clients, collect actionable feedback,
lock a direction before the remaining pages are designed. Adi picks it up next.

Filled in `brief.md` (was all TBD) and wrote
`.logs/planning/inputs-2026-08-28.md` — a full teardown of the three inputs:
Adi's `Proto Preview` WordPress prototype (data model, admin, overview,
viewer, pins, Play/Comment modes, notifications), the hartlingowners guided
walkthrough as a way to *present* a concept rather than just send it, and the
current state of easel (bare BW shell, no product code).

Adi's video is transcribed to
`notes/adi-invision-app-idea.transcript.md` — no need to rewatch it.

**Five open questions are recorded in `brief.md` and none are decided.** The
load-bearing one: Adi wants clients to comment **without logging in**, but
easel is scaffolded so everything sits behind BW sign-in. Second: Adi's work is
a WordPress plugin and easel is FastAPI+React — port or reuse. Both are rian's
and Adi's calls, not a next session's assumption.

No product code written. Nothing deployed.

## 2026-08-28 — BW app scaffolded and deployed; v8 workspace docs added after the fact

Built via the one-prompt BW app standard: `create-project` → `new-bw-app.sh`
→ `app-client-register` → `deploy --build`. 50/62 conformance assertions pass
(12 skipped — instance-scoped tests, since `has_instances=false`).

Hit a real port collision twice during creation (`3151`, `3152`) — both
already bound by `hartlingowners`' manually-configured multi-instance ports,
which the gateway's port registry doesn't track. Landed on `3153` after two
delete/recreate cycles. See `agents.md` Operational section.

The v8 workspace layer (`brief.md`, this file, `.logs/diary.md`,
`.logs/planning/`, `.memory/`, `.archive/`, `notes/`) was **not** added by the
BW scaffolder — it only produces the app/account-system side. Added
separately, after rian noticed it was missing.
