# punchlist — handoff

## 2026-08-24 — workspace broken out into its own session (context sync, no code)

Punchlist now runs in its OWN Claude session (cwd `/srv/apps/punchlist`);
every entry below this one happened inside the caddie session. What was
gathered so the break-out loses nothing:

- **Session memory seeded** at `~/.claude/projects/-srv-apps-punchlist/memory/`:
  `rian-motion-language` (copied from the caddie session's memory — the motion
  taste rules) + `caddie-integration` (framework doc paths, learnings flow,
  where pre-breakout history lives).
- **Caddie M1 (the bare spine) is being built 2026-08-24 in the parallel
  caddie session** — backend spine + conformance green (69 tests), frontend
  (client project view) in progress; not yet deployed. Changes nothing for
  punchlist: the drop-ins (`caddie_tool.py`/`caddieHost.ts`) remain caddie M2,
  so plan §9 seams-only stands. Punchlist learnings still get appended to
  caddie's `05-building-a-caddie-app.md` §5.
- Verified live: v0.15.0 at `/api/meta`, both containers healthy, 123 tests
  per last session.

Next: author the real client punchlist (per `notes/authoring-workflows.md` +
the seed library) and send it out on punchlist.bowden.works.

Ported the remaining lab control types into the spec and matched each Keith
item to one. A step now declares `control` — `button` (default), `input`,
`upload`, `link` — or `states` for a status list.

**The organising rule, now in the authoring standard:** the row's checkbox
marks the item done, so a control exists ONLY to collect something or to send
the client somewhere. A step that needs neither declares nothing.

Live on keith-barnet-site-v2, verified per item: invoice → status dropdown;
brand guide → upload; Google Ads → inline field (no button at all); photos →
link to the Drive folder with the checkbox recording it; hosting → inline
select; Barnet/Facebook/Wix → checkbox only; GA4/Merchant/GBP → target
counters; age verification → toggle.

**`states` is the one new engine concept.** Intermediate states record and
STAY (where a thing is up to is worth knowing on its own); only the last state
advances the step; it can be moved backwards; and the value reads downstream
as `{{fields.<step>.state}}` — reusing the synthetic-field trick that
`choice` already used, so no second interpolation path.

**Upload reuses the item attachment endpoint** that shipped in 0.9.0 for
comments — it was already item-scoped, so the control uploads then acts with
the returned url as the field value. No new storage.

**A silent behaviour change I should have flagged at the time:** I loosened the
Google Ads customer-ID pattern so dashes are optional, and only noticed when
`test_engine` failed. Kept it (people paste it both ways, and refusing a
correct ID over punctuation stalls an item for a day) but the test now states
the intent explicitly rather than encoding the old rule by accident.

**Still not ported, and both need a decision first:**
- **Untick-to-undo** — needs a compensating-event action in an append-only
  engine.
- **Sub-items within one step** (the "Review add-ons 0/2" case) — the one
  genuinely new structure left; `targets` generalised from a list of emails to
  a list of questions.

123 tests pass.

## 2026-08-23 — the lab UI landed in the app (v0.14.0)

Ported the settled parts of the controls lab onto the real punchlist, live on
keith-barnet-site-v2.

**What shipped:**
- **A checkbox on every row**, and the item's ⋯ menu moved onto it (off the
  control). Same tab, third orientation, revealed by CLIPPING rightwards over
  the label; peeks for the checkbox only, not the row.
- **Claim-only steps lost their button.** A step with no fields, no targets
  and no choices is a claim, and the checkbox already takes claims — ticking
  it calls `act({action:"primary"})`. Verified live: ticking the invoice
  advanced it a step. Rows with something to enter/choose/fan out keep their
  control.
- **Unanswered choice toggles are outlines**, filled only once answered.
- Per-item timings for the box tab (520ms peek / 1500ms breath / 2200ms gap,
  700ms open), matching the lab.

**The checkbox means "not waiting on you"** (`!(canAct && !waiting)`). That is
the honest mapping for a multi-step workflow: the client ticks when their part
is done, the item passes to the team, and the tick stays. It is NOT the same
as the item being finished.

**NOT ported — needs engine work, decide before promising it:**
- **Untick-to-undo.** The lab lets you untick anything and it resets the
  controls. The engine is append-only with no undo action, so a ticked box is
  currently inert. Doing this properly means an `undo`/`revert` action that
  appends a compensating event (never deletes), plus a rule for how far back
  it may go. Worth designing rather than bolting on.
- **The new control TYPES** (input, upload, status, combo) — those need spec
  types, validation and engine support, not just UI. `choices` was the first
  and the pattern is established; `02-action-controls.md` has the order.

**Test artifact worth knowing:** React synthesises `onMouseEnter` from
`mouseover`, so dispatching a raw `mouseenter` from JS does NOT reach a React
handler — the state machine looked dead until I hovered for real via CDP. Use
a real hover (or dispatch `mouseover`) when testing React hover behaviour.

117 tests pass.

## 2026-08-23 — lab round nineteen: the checkbox menu, polished

Rian: the peek is too slow, the breath is "slow and jittery (like a low frame
rate look)", the slide-out on hover is good, and it should peek only on
CHECKBOX hover rather than row hover.

**The jitter had a real cause, not a timing one.** The tab grew by animating
`width`, which re-lays the element out every frame and rounds to whole pixels.
Over a 2.7px breath stretched across 1.5s that is a handful of discrete steps
— exactly the slideshow look he described. Fixed by making the reveal a
**clip** (`clip-path: inset(… round 6px)` on an element that is always full
width): sub-pixel smooth, and it never touches layout. Same growing behaviour,
and `--box-icons` still drives it.

**It also needed its own pace.** This tab travels a few px where the others
travel dozens, so the shared 1000/3000ms felt sluggish here: 520ms peek,
1500ms breath, 2200ms gap; the 700ms open left alone ("the slide out on hover
is good"). The state machine now reads per-instance timings off `data-breath`
/ `data-gap` / `data-after`, so a component can be quicker without forking the
machine — worth keeping in the app.

**Peek is now bound to the checkbox**, not the row.

Verified: clip goes 34 → 28.56 → 25.84 → 0 with width constant at 56 (so it
is genuinely clipping, not resizing); bump 0.52s, open 0.7s; hovering the ROW
leaves it at rest while hovering the checkbox bumps and hovering the tab opens.

**General rule worth carrying:** if a small movement looks like it is dropping
frames, suspect the PROPERTY before the duration. Anything that triggers
layout (width, height, top/left) steps at whole pixels; transform, opacity and
clip-path interpolate smoothly.

## 2026-08-23 — lab round eighteen: outline-when-unanswered, and a naming bug

Rian spotted a dropdown on #2 "with 2 undefined items in it. I think it's just
a bug." It was, and it was a naming collision worth remembering: **`options`
meant two different things** — a control's ANSWERS (Yes/No, the combo's
presets) and its ALTS (other ways to answer the item, on the ⌄). `withOptions`
fired on any `options`, so the yes/no toggle's answers were rendered as menu
items with no `to`/`label`, hence "undefined". Renamed the alternatives key to
`alts`; the two can no longer collide.

**Unanswered is now an outline.** Rian: both unselected should be outline
style, and selecting fills one. He is right for a reason beyond taste — a
filled accent means "answered" everywhere else in this system, so an
unanswered control must not wear it, and two filled halves read as two
buttons rather than one question.

Verified with transitions disabled (this tab's compositor is still stalled, so
computed backgrounds otherwise read as the pre-transition value): unanswered
both transparent inside a hairline outline; answered → chosen accent-filled,
the other chip-toned; retracting returns both to outline. #5's dropdown still
works on the renamed key.

**Verification note for next time:** `color` is not in `.ctl`'s transition
list but `background` is, so in a stalled tab the text colour updates and the
background does not. That mismatch is a good tell that you are looking at the
compositor bug rather than a CSS bug — re-read with `transition: none` before
diagnosing.

## 2026-08-23 — lab round seventeen: everything is retractable

Rian: yes/no needs no dropdown, but a choice must be un-selectable — "by
clicking the selected item or unchecking the checkbox."

Both built, and the second half generalised into a rule worth keeping:

**You cannot TICK a derived checkbox, but you can always UNTICK one — and
unticking undoes whatever ticked it.** `resetRow()` puts every control in the
row back to unanswered: choices cleared, checks unticked, status to state 0,
settled pills restored, uploads emptied, textareas cleared, counters back to
0/n. So the asymmetry is: ticking is earned by doing the thing, unticking is
always available.

**Every answer is retractable.** Clicking the choice you already made takes it
back. Same reasoning as the earlier escape-hatch rule — an answer you cannot
retract is one people hesitate to give, and hesitation is what this design
exists to remove.

**A two-option toggle carries no ⋯** — with both answers in front of you, a
menu offering "more options" is offering nothing. Removed from #9's sub-rows
(#2's was already bare).

Verified: #2 selects → done, click-again → un-selects and un-dones, re-select
then untick the row → both cleared; #9 sub-toggles have no carets, 2/2 →
done, untick → 0/2 with nothing settled; and a derived box (#7) still cannot
be ticked by clicking it.

## 2026-08-23 — lab round sixteen: many items need NO control

Rian drew the line the row checkbox implies: **the checkbox marks it done; the
control on the right, if there is one, is for options and inputs.** A button
that only said "Access added" was saying what the checkbox already says, so
items whose whole answer is a claim now have **no control at all** — #1, and
both rows of #3. A checkbox, a label, and its ⋯.

**This reverses something I wrote two rounds ago.** I had noted that `manual`
completion "must stay rare — a manual tick is a claim with nothing behind it."
Wrong once the button goes: for a claim item the checkbox IS the claim, and
there was never anything behind the button either. Manual is now the norm;
deriving still applies wherever a control produces a real signal (value,
selection, count, terminal state). Plan updated.

Also this round:
- **#5's path moved onto the control** as a fused ⌄ ("Provide brand notes"),
  which is where the glyph rule says it belongs — ⌄ is what THIS control can
  become, ⋯ is what to do about the item. New `withOptions()` wrapper does
  this for any body, so it is not upload-specific.
- **#8** wording → "Mark as Granted".
- **#10** placeholder → "My chosen hosting company…", and the "I don't know
  who hosts it" option dropped (the item asks them to CHOOSE a host, so not
  knowing the current one isn't an answer to it).

Verified: #1 and #3 have zero controls and tick directly, both keep their
checkbox ⋯; #5's dropdown lists "Provide brand notes", switches, opens in the
same motion and keeps a ⌄ back to the upload; #8 reads "Mark as Granted";
#10 has the new placeholder and one option.

## 2026-08-23 — lab round fifteen: the checkbox menu GROWS

Rian, refining the previous round: slide it out to the RIGHT over the label
instead of left into a gutter; make the checkbox bigger; keep it completely
invisible until hovered; and — the important one — **"I'm imagining that I may
want to add a second icon beside the ..., so I think we need the style to work
so that it grows out instead of slides back and forth."**

That last point is the real design decision. A sliding tab has a fixed width,
so adding an icon means re-tuning the travel. A GROWING one just gets wider:
`--box-icons: 2` and every state follows, because bump and swell are now
**fractions of the growth** (0.16 / 0.24 — the same proportions as the sliding
variants, since they are 100% + `--bump-at` and 100% + `--breathe-to`).

Built: the tab starts exactly the checkbox's size and hidden behind it, so at
rest a row shows an ordinary checkbox and nothing else. It grows rightwards
OVER the label, so no gutter is reserved and nothing shifts. Checkbox up from
19px to 22px. Verified anchored-left in every state, widths 22 → 27.4 → 30.2 →
56, showing 0 / 5.4 / 8.2 / 34px past the checkbox, label never moves, tab
overlaps the label when open.

**Bug worth remembering — variant rules inherit.** `.grp.left` is still a
`.grp`, so the sliding variant's state rules (`.grp[data-state="bump"] .caret
{ transform: translateX(var(--bump-at)) }`) kept applying and yanked the
growing tab 24px sideways on top of its width change. Setting `transform:
none` on the BASE `.caret.left` was not enough — the state rules out-specify
it. A new variant of a stateful component has to cancel the old variant's
state rules explicitly, not just its resting style.

## 2026-08-23 — lab round fourteen: the menu moved onto the checkbox

Rian: "Can we try putting the ... menu on the checkbox? So it peeks out the
same way, but instead of coming out of the button, it comes out of the
checkbox?"

Done, and it reads better than I expected: the item's outs now sit at the
item's LEADING edge, beside the thing that says whether the item is done,
rather than hanging off whichever control happens to answer it. The control on
the right is bare.

**Third orientation of the same tab** — right off a button, up out of a field,
now left out of a checkbox. Distances reuse the positive values already
derived for the vertical tab (`--vbump-at`, `--vbreathe-to`), so all three
axes still come from ONE tuning. Verified as an exact mirror: 0 / 4.32 /
6.48 / 27px clear of the checkbox, and the four-state machine runs on it
unchanged (bump on row hover → open on tab hover → rest on leave).

Sub-row and field controls keep their own ⋯ — those are options for THAT
control, not for the item. Only the item-level menu moved.

**Geometry rule now written into the plan, having been got wrong twice:** the
runway must equal the tab's width. At rest the tab is pushed exactly its own
width behind its anchor, so any extra padding is a permanently visible sliver
(here a 32px runway under a 27px tab left 5px showing).

## 2026-08-22 — lab round thirteen: the row checkbox (the unifying idea)

Rian, seeing it come together: "each item does have sort of a 'done' stage in
one way or another... maybe having a checkbox on all of the items." **This is
the idea that ties the whole system together** and it is worth treating as
settled architecture, not a styling round.

**A checkbox sits left of every label — and it is DERIVED. You never tick it;
doing the thing ticks it.** Each control type declares what done means, and
rian specified all ten (in the plan as a table). The one exception is `manual`
for the Drive-folder case, where nothing observable tells us.

Three structural consequences he called himself:
- **Controls give up their own checkboxes.** `check` became a plain button and
  `linkcheck` went back to a plain link — two checkboxes on one row would be
  two answers to one question.
- **Progress moved beside the checkbox**, out of the counter control: the
  count and the tick both answer "where is this up to".
- **A skip is an answer.** The notes path completes when every field is
  answered OR deliberately skipped, which is what makes that form finishable.

Implementation note for the app: completion lives in ONE `evaluate(row)`
function keyed off a `complete` mode on the row, and every interaction calls
`evaluateAll()`. That is deliberate — a per-control "report done" callback is
the version that gets forgotten when someone adds type eleven.

All ten verified end to end, including the negative cases: a 3-digit customer
ID does NOT complete #4 while a 10-digit one does; 2/3 grants does not
complete #8 and un-ticking one un-completes it; three of four notes fields
does not complete #5 and undoing a skip un-completes it.

Bug found in verification: the field escape hatches ("no specific preference")
weren't re-evaluating, so a deliberate skip didn't count toward completion —
the exact case rian singled out. Every mutation path calls `evaluateAll()` now.

## 2026-08-22 — lab round twelve: combo (#10)

I had flagged #10 as probably-cut ("too clever"). Rian's answer was better
than my proposed cut: **make it a combobox** — the "my host company" field
gets the control's own ⌄, with the standing options behind it. Most clients
type the host they already use; the preset is there for the rest.

The rule that fell out and is worth holding generally: **when a control has
more than one input path, they all settle into the SAME answered shape.**
Typing "SiteGround" and picking "PlusROI hosting — $29/mo" produce an
identical pill, so the control never looks like two different things depending
on how you reached it. The pencil reopens either.

That also let me generalise `settle()` and give the pencil a real handler, so
every settled state in the lab is now reversible — consistent with the earlier
"an escape hatch is a toggle, never a one-way door" rule.

The old hosting-specific machinery (focus-reveals-a-sub-row, `data-pick`,
`data-hosting`) is gone entirely; the combobox replaced it with less code.

Verified: anatomy field→chevron→menu, both options listed, picking settles to
the right value, pencil returns an empty field, typing settles identically,
⋯ caret intact throughout.

**Verification note:** javascript_exec calls share one page session, so an
earlier test's clicks leave state behind and the next read looks wrong
(I briefly chased a "missing combo" that was just a settled control from my
own prior run). Navigate first, then assert.

## 2026-08-22 — lab round eleven: inline targets, split questions

Rian refined the previous round's layout rather than accepting it, and the
result is a better rule than "always stack":

**Two sub-layouts, chosen by what the text IS.**
- **Inline** (targets): put the address INTO the control ("Granted —
  rian@plusroi.com") and let them flow on one line. No captions, compact —
  all three now fit on a single row.
- **Split** (sub-items/questions): the text is the question being answered, so
  it leads on the left and the control answers on the right.

My previous round stacked everything control-above-caption, which is right
only when there IS a caption. For targets it produced "Granted / Granted /
Granted" down the page with the addresses orphaned underneath — exactly the
concern I raised at the time, and rian's fix was the one I'd flagged: put the
identity in the control.

**Prose moved below the actions** inside the expanded item ("In
analytics.google.com…", "Neither is required…"). Same principle as the
sub-rows, one level up.

Bug caught in verification: the counter read 0/0 for the inline layout because
`recount` only counted `.subrow`, and inline items are `.subitem`. Both are
counted now — verified 0/3 → 1/3 → 3/3 → 2/3 on untick, and the split layout
still counts correctly.

## 2026-08-22 — lab round ten: status dropdown, action-before-text

**Status became a dropdown.** Rian: some of these will have more than three
states, so the inline track had to go. Now a menu of states with two rules
worth keeping:
- **The first state is named from the CLIENT's side** — "Waiting on invoice",
  not "Sent". The list is what they see, so it is named for their position in
  it, not ours. Good general principle for status vocabularies.
- **The last state is terminal and the control puts on its checkbox.**
  Reaching the end of the list IS done — no separate "now mark it complete".
  Unticking steps back one state.

**Action first, explanation after.** Sub-rows read text-left/control-right,
mirroring item rows. Rian flipped them: control on top, text as a caption
beneath. His stated principle is broader than sub-rows — "focus on the action
first and put the explanations and text and stuff further down in general" —
so treat it as a layout default, not a one-off.

Target checks also lost the ghost variant for the standard fill. Ghost now
appears nowhere; keep it only if something is genuinely secondary.

Verified: status starts "Waiting on invoice" with the box hidden, opens to all
three, picking the last ticks it and shows the box, unticking returns to
"Invoice received"; both sub-lists now stack control-then-caption with standard
fills, and the add-ons toggles are untouched (rian: "the yes no buttons look
great").

**Process note:** a failed assertion mid-script meant NONE of that batch's
edits were written (the write is after all the replacements) — but the
following `docker cp` still ran and cheerfully deployed the unchanged file.
When patching in bulk, check the script actually reached its write before
trusting the deploy.

## 2026-08-22 — lab round nine: linkcheck (#6 merged into one control)

Rian on the previous round: "its perfect, I love it now" — the branch case,
the vertical tab, the menu fix and the fold-on-tick are all settled.

Then #6: I had built it as TWO controls sharing a row (a link button beside a
ghost check) and called that "composition". Rian merged them into one:
**checkbox on the left, ↗ on the right, label between them changing with the
state** — "Upload to Google Drive" → "Uploaded".

He's right and it's a better rule than mine: **a control that both travels and
records is one object, not two.** Two hit zones inside it — the box records,
everything else travels. The ↗ sits at low opacity (a signpost, not a second
button competing for attention) and the box scales a little on hover so its
separate target is discoverable.

New `linkcheck` body in the registry; the old `link` type is gone. Verified:
one group on the row, anatomy box→text→↗, body click travels without changing
state, box click ticks and relabels, unticks and relabels back, caret intact.

## 2026-08-22 — lab round eight: the menu was clipped, not layered

Rian: the vertical tab "works, and I like it", but "the menu appears behind
the components", plus: ticking "Notes added" should fold the item away.

**The menu turned out to be TWO problems stacked**, and my first diagnosis was
only half right:
1. **Trapped stacking context.** `.vgrp` had `z-index: 1`, which made it a
   stacking context — so the menu inside was capped at level 1 no matter what
   z-index it carried. Fixed by leaving the wrapper at `auto` and letting the
   tab (1, under its field so it can hide) and the menu (60) carry their own.
2. **The real one: it was being CLIPPED, not painted behind.** The
   `grid-template-rows` expansion needs `overflow: hidden` on the inner box,
   which cuts off anything trying to escape. After fixing the z-index the menu
   still failed hit-testing, which is what exposed it.

Fix: release the clip once the row finishes opening, re-apply it the moment it
starts closing. **Deliberately a timer, not `transitionend`** — I wrote it with
`transitionend` first and it never fired, because this tab's compositor is
stalled. That is not just a test artifact: the same thing happens in a
backgrounded tab, and the failure mode is the clip staying on forever and
silently breaking every menu inside an expanded item. Both traps are written
into the plan, because **the app uses the identical expansion mechanism** and
will hit both.

**Ticking the row's own check now folds the item** (and unticking reopens it —
the symmetric reading of "not done after all"). Verified both directions.

Verified: clip released to `visible`, menu hit-testable at every point along
its height (its own buttons topmost), tick → closed, untick → reopened.

## 2026-08-22 — lab round seven: the vertical tab was invisible

Rian: "I don't see the peek on hover, or the breath, and because I never see
it I can't hover it to have it glide out." Correct, and the cause was my
geometry, not the machine.

**The bug:** I anchored the vertical tab at the FIELD'S TOP (`top: 0`) and slid
it down to hide. But the field is opaque and paints above it, so even at
`translateY(0)` — "fully open" — the tab sat over the field's first 21px,
*behind* it. It was invisible in every single state, which also made it
unhoverable, so the whole four-state machine ran correctly and could never be
seen or reached.

**The fix:** anchor at `bottom: 100%` so the tab HANGS ABOVE the field's top
edge and slides DOWN behind it to hide. Now measured as visible-above-field:
rest 0px, bump 3.36px, swell 5.04px, open 21px — the horizontal behaviour
rotated exactly.

**The lesson worth keeping:** I verified the transform TARGETS last round
(21 → 17.64 → 15.96 → 0) and they were all correct — the numbers were right
and the thing was still invisible. **Measuring an element's own transform
proves nothing about whether a user can see it.** For anything that reveals by
emerging from behind something else, assert on the RELATIONSHIP — how much of
it clears the occluding edge — not on its own position.

Also brightened the dot roll-out per rian ("maybe too subtle"): rest scale
0.45→0.3 and opacity 0.4→0.25 (more growth to see), stagger 75/150→110/220ms,
overshoot 1.55→1.8.

Note for later: at bump only ~3.4px of the tab is exposed, and the rest is
behind the field, so that sliver is the entire hover target. Same as the
horizontal caret's ~4px, so it is consistent — but if it proves fiddly to hit,
the fix is a transparent hit area extending UPWARD into the dead space between
the label and the field, which costs nothing.

## 2026-08-22 — lab round six: one choreography, two axes

Rian rejected the static ghost menu I'd put on form fields — "too far out of
consistency" — and specified the fix himself: same colour, same shape, same
peek-on-hover / breathe-on-linger / glide-on-hover, but for a full-width field
it **glides UP out of the top-right corner**.

Built. **The rule is now: the choreography is universal, only the geometry
adapts.**
- Button-shaped controls anywhere (including inside a form) keep the sideways
  caret.
- Full-width fields get `.vtab` — tucked behind the field's top edge, gliding
  up. Same states, same timings, same seam rule (the field squares off the
  corner the tab came out of), same menu contents.
- **Vertical distances are derived in CSS from the horizontal ones**
  (`--vbump-at: calc(var(--bump-at) * -1)`), so one tuning drives both axes and
  they cannot drift. Verified mirror-exact: 21 → 17.64 → 15.96 → 0, i.e.
  100/84/76/0.
- One state machine drives both shapes; it now hovers the nearest COMPONENT
  (`.fieldrow` before `.row`), so a field's tab answers to its own field.

**The dots now roll out** as rian described: bunched and small while tucked,
unfurling one at a time on the open with staggered starts (0/75/150ms) and a
gentle overshoot each. His words: "very subtle touch if you know what I mean"
— so the exaggeration he described is deliberately damped.

Verified: logo field keeps the sideways caret, three textareas carry vertical
tabs, both wired, escape hatch works through the vertical tab and reverting
re-wires the restored tab.

## 2026-08-22 — lab round five: field-level escape hatches

Rian confirmed the motion is right ("I'm checking these move. It's the right
direction") and gave four corrections, all built:

1. **Omit the fluff.** "Actually, I do have a guide" → "Upload brand guide";
   "No guide — I'll give you notes" → "Provide brand notes". A menu item names
   the road; it doesn't narrate the client's situation. (Same instinct as the
   button-label rule — this is the third time wordiness has been the note, so
   treat terseness as the default when writing ANY label.)
2. **Switching path opens the form in the same motion** — no second click to
   see what you just chose.
3. **"Add notes" became a "Notes added" CHECK**, not a button. Right call: the
   form saves as you go, so the only thing left to say is that you're done.
4. **Every field inside the form gets its own ⋯** — logo row offers "take them
   from our current site", each notes box offers "no specific preference".

**The pattern that emerged and is now in the plan: an escape hatch is a
TOGGLE, never a one-way door.** Choosing one replaces the input with a ticked
check; unticking restores exactly what was there (the original markup is
stashed on the element). Applies to the item-level path swap too, which
round-trips.

A field's ⋯ is deliberately NOT tucked like a control's — nothing to its left
to hide behind, so it rests as a quiet ghost at the label line. Same glyph,
same menu structure, different resting state.

Verified: menu label, swap → check + open in one motion, 4 field menus, logo
hatch → "Taking them from your site" ticked → untick restores the upload,
colour hatch → "No preference" ticked with the box gone → untick restores the
textarea, and the item-level menu offering "Upload brand guide" to go back.

## 2026-08-22 — lab round four: the branch case (type 5)

Rian approved types 1–4 ("wonderful so far") and specified the hard one: the
client with no brand guide needs to switch to a different way of answering,
and he wants that living in the ⋯ menu.

Built exactly as specified: ⋯ opens with **"No guide — I'll give you notes"
ABOVE the "Can't do this right now?" section**, choosing it turns the action
into **Add notes**, and clicking that expands the item onto a form — logo
upload, colour notes, font notes, anything-else, all with teaching
placeholders rather than bare labels.

**The distinction this forced, now written into the plan:** an alternative
PATH is not an OUT. "No guide" is a different road to the same destination;
"I'll do it later" is a deferral. They get separate regions in the menu, paths
first. In the spec a path is just an `alternatives` entry with `kind: "jump"`
whose target step has a different control and its own fields — **no new engine
concept**, only a rendering rule that jumps sort above flags.

Two things the app implementation must not lose:
- **The swap is symmetric** — "Actually, I do have a guide" restores the
  upload control AND the original expanded content, through the same code
  path with the other argument. (I got this wrong first pass: the back option
  re-rendered the notes form, because both matched one `[data-path]` handler.)
- **Runtime-created controls need re-wiring** — a swapped-in control has a
  fresh caret, so the choreography state machine must re-attach
  (`wireCarets(row)`, idempotent via `data-wired`). React gives this free.

Verified end to end: menu order correct, switch → "Add notes" → opens with 3
textareas + the logo upload, switch back → upload control restored, detail
text restored, row closed, form gone.

Still true from round three: **nobody has watched any of this move** — this
tab's compositor is stalled (transitions never advance, screenshots time out).
Values are proven, motion is not.

## 2026-08-22 — controls lab round three: the choreography, ported properly

Rian: "we're going backwards a bit as we're losing the nuance I spent so long
working on with the way the hover/dwell/peek works. the speed, timing, etc."
He was right. **I rebuilt the lab's hover behaviour from scratch instead of
porting the app's**, and flattened the tuning without noticing.

**What I'd lost, precisely:** row hover made the menu slide FULLY out. The
tuned behaviour is that row hover only **bumps** it 16% (`--bump-at: -84%`);
full open happens when you hover **the bump itself**. Losing that distinction
takes the breathing, the 3s pauses and the engaged return pace with it —
because they only exist in the gap between bump and open.

Now ported verbatim: tokens (`--bump-ms` 1000ms, `--bump-at` -84%,
`--breathe-ms` 3000ms, `--breathe-to` -76%, `--open-ms` 700ms, `--open-ease`
cubic-bezier(0.34,1,0.64,1), `--half-breath`) and the four-state machine from
ItemCard (rest → bump → swell → open; BREATHE_AFTER/BREATH_MS/BREATH_GAP all
3000; `.breathing` half-pace; `.engaged` so the return matches the opening).
The dots' spread is bound to the same states, standing in for the chevron's
unfold. **Rule now written into the plan: port the choreography, never
re-derive it.**

Verified: the state sequence runs exactly (rest → bump → swell → bump while
breathing → open+engaged on caret hover → bump on caret leave → rest on row
leave), and each state's transform target is exact — rest -26px (-100%), bump
-21.84 (-84%), swell -19.76 (-76%), open 0; dots 3.5px bunched → 0 spread.

**Caveat, and worth knowing for next session:** this Chrome tab's compositor is
stalled — transitions never advance (`getAnimations()` shows a transform
transition that never completes, and even an INLINE percentage transform reads
back as the pre-transition value), and screenshots time out with "renderer may
be frozen". Same root cause for both. I measured the targets with
`transition: none` to prove the CSS, but **nobody has watched this move yet**.
A fresh browser session should just look at it.

Also fixed: the state machine was throwing on the anatomy sample (not inside a
`.row`, so `closest(".row")` was null) which unwired every group after it —
one un-guarded `closest()` silently killed the whole feature.

## 2026-08-22 — controls lab round two: peek + the ⋯/⌄ rule

Rian wanted the peek animations in the controls lab "so I can get a real sense
of what that looks like with all these", and named a collision I'd missed:
two dropdown-ish affordances, both chevrons.

**The rule he set, worth holding everywhere:**
> **⋯ opens a MENU of other options. ⌄ EXPANDS this thing in place.**

Lab now has both:
- **The alternatives menu is three dots**, and it **tucks behind the control
  at rest**, sliding out through a reserved runway on row hover — so at rest a
  row shows nothing but its control, which is a big readability win with ten
  control types on one page. The dots **spread apart** as they emerge; that
  replaces the chevron's fold/unfold as the flourish, since dots can't unfold.
- **The chevron belongs only to things that unfold**: the counter control, and
  the peek's pull-down handle.
- **Full peek behaviour ported**: 0.5s dwell on the LABEL (never the controls)
  → overlay peek with a one-line hint and the centred pull-down handle; click
  the label or the peek → the row opens showing the detail and, on counter
  rows, the sub-rows. Includes the last-row edge treatment and the card's
  radius hand-over. Every row got real hint + detail copy so it reads like the
  app rather than lorem.

**THE APP STILL HAS THE OLD COLLISION.** Applying the rule there retires the
chevron-unfold on the reveal segment — the animation rian specifically liked
("spun from up to down", from the Cute preset). The dots-spread is the
proposed replacement; show him before changing it.

Verified functionally in-browser: dwell→peek (72px, pull-down at opacity 1,
right hint text), click→open (peek clears, detail renders, unfold has real
height), caret slides fully out, dots spread, and the glyph split confirmed
(menus = dots, counter = chevron). **Not verified visually this round** — the
Chrome tooling wedged on screenshots partway through ("Cannot access a
chrome-extension:// URL"), across both a fresh tab and a reload. The page
itself is fine; it's the harness. Live at punchlist.bowden.works/controls.html
and artifact a94dfeef-1efb-4fc4-bb34-894440e18eb0.

## 2026-08-22 — pace settled, and the controls lab (v0.13.2)

**Pace: durations halved** (760ms reveal / 1100ms expand). Sequence worth
remembering — rian asked for 2× slower twice, then once the CURVE was fixed
asked for 2× faster: with constant-speed motion the same distance reads far
slower, so the long durations had only been compensating for the ease-out.
Curve first, duration second.

**The controls lab is built and published** — rian's call, before any
implementation: "build the UI for all the types in the lab first… I want to
build this system so it's flexible, not locked into a few rigid components."
`scratchpad/action-controls-lab.html`, served at
`punchlist.bowden.works/controls.html` (wiped each deploy — `docker cp` to
resync), artifact a94dfeef-1efb-4fc4-bb34-894440e18eb0.

Ten types, all live: button · choices · check · input · upload · link+check ·
status track · counter→per-target checks · counter→sub-item toggles · input→
standing option. Every one verified working in the browser.

**The finding that should drive implementation:** they are not ten widgets,
they are ONE CHASSIS WITH SWAPPABLE BODIES, and in the lab that is literally
the code — a `BODY` map of render functions plus a single `group()` that wraps
any body in the chassis + caret. Adding a type = one entry in `BODY`. **The
app should keep that shape**, not grow a branch per type inside ItemCard.
A `ghost` variant emerged and earns its keep (secondary control beside a
filled one; repeated down a sub-list without shouting).

I flagged type 10 as probably-cut in the lab itself: a plain `choices` with an
"our own host" branch gets there with far less machinery.

Notes folded into `.logs/planning/02-action-controls.md`.

**Two lab bugs worth remembering** (both mine, both instructive):
- `.sub` was used for BOTH the intro paragraphs and the expanding container,
  so the intro inherited `opacity: 0` and left a mystery gap. Renamed
  `.unfold`.
- `blur` with capture never fired under automation (the tab isn't truly
  focused). Switched to `focusout`/`focusin`, which bubble — more reliable in
  real browsers too, not just for testing.

## 2026-08-22 — the movement curve, and the vanishing row (v0.13.0–0.13.1)

**Rian diagnosed the animation himself and was right.** "It's not about the
animation time, it's about the speed of the movement. It starts fast and slows
down." Everything was on an ease-OUT (`cubic-bezier(0.25,0.8,0.3,1)`), which
covers most of the distance immediately then crawls — so it reads as "whooshed
out, then a slow creep", and MORE DURATION MAKES IT WORSE (a longer crawl).
Calm is a speed property, not a duration property.
- New `--glide` token: near-constant speed, slight smoothing at the ends. All
  reveal MOVEMENT uses it (peek, expand, target fan-out, card radius, and the
  segment bump). `--soft` stays for fades and hovers, which should still land
  softly — opacity has no sense of speed, only presence.
- Two alternatives are written into the CSS comment, one line each:
  creep-then-accelerate `cubic-bezier(0.55,0.06,0.72,0.35)`, or `linear`.

**"When I clicked yes the item disappeared" — two separate causes.**
1. **The real bug:** the list sorted GLOBALLY (demoting anything
   `waiting_on_us`) and grouped into sections AFTERWARDS. Grouping works on
   consecutive runs, so the demoted item was torn out of its neighbours and
   rendered under a **second "Decisions" heading at the bottom of the page**.
   Fixed: group first, order within each group, and **acting no longer
   reorders a row at all** — only a raised flag moves one. NOTE the side
   effect: section order is now the authored position order (Billing first),
   because the old order was an accident of the sort I removed.
2. **The design point:** answering handed the item to the team and left the
   client with nothing to show for it. The same control now stays on the row
   with the answer lit and the road-not-taken dimmed (read-only), and the
   answer follows the item into Done as a chip. Backend exposes `choice_made`
   carrying the whole option set, so the control can render settled after the
   step it belonged to is no longer current.

Also: Yes / No, not "Yes, add it / No, skip it".

**The big one is designed but NOT built: `.logs/planning/02-action-controls.md.**
Rian's whole message is a control-type system — `check`, `input`, `upload`,
`link`, `status` beside the existing button/choices, plus per-target controls
(GA4 as three checkboxes, each with its own caret) and sub-items within a step
(the "Review add-ons (0/2)" case). The doc records his examples, the rules,
and a build order (`check` first — it appears in the most examples). **Read it
with him before building; it is a system, not a feature.** One line in it is
worth keeping in mind: the reason this is NOT a form is that each control
commits on use, which is exactly why the answer must stay visible afterwards.

117 tests pass.

## 2026-08-22 — actions that are answers (v0.12.0–0.12.3)

**The important one: `choices`.** Rian's critique was exact — "Submit choice…
what choice? It's not visible so not clear until the user clicks it and sees
what the choices are. Then there's a second 'submit decision' below." A step
now takes EITHER a `primary` button OR `choices`: 2–4 mutually exclusive
answers rendered as one inline segmented control, fused to the same
alternatives caret. The decision happens on the row in one tap.
- Each choice carries its own `to`, so choices BRANCH. That is the real answer
  to hosting-style steps: "our own host" jumps to the step that asks which,
  instead of a conditional field nobody can see.
- The chosen LABEL is written as a synthetic field value, so downstream prose
  reads it via the EXISTING `{{fields.<step>.choice}}` token — no second
  interpolation path to keep in sync. The team's step now says what was picked.
- Guards (all tested, all with pointed messages): button XOR choices; 2–4
  answers; no fields or targets on a choice step; no key shadowing an engine
  action or an alternative key.
- **`decide_age_verification` is the first conversion** and is live on the
  demo — plus a short guide on what age verification actually does, so the
  answer is easy. `choose_hosting` and `decide_optional_addons` are the
  obvious next two; rian said he'd go down the list himself.

**Still to build from his message** (he described it, I have NOT done it):
the collapsed/expanded label idea — "Review add-ons (0/2)" collapsed, and on
expand the button moves to the bottom-right and becomes "Submit decisions",
with a yes/no toggle beside EACH add-on. That needs per-sub-item answers
inside one step, which `choices` doesn't cover yet — probably a repeating
choice over a list variable, the way `targets` repeats over an email list.
Worth designing properly rather than bolting on.

**Also this round:** pace doubled AGAIN (`--reveal-ms` 1520ms, `--expand-ms`
2200ms — still two lines to tune); the peek's chevron centred as a pull-down
handle; the tutorial moved into the alternatives dropdown, leading it.

**Three fixes found by looking, not by testing:**
- The toggle's last option was clipped by the caret tucked beneath it — the
  single button has `z-index: 2` for exactly this and the toggle didn't.
- The chevron at border-grey over running text was invisible.
- Then centred ON the text it punched a hole mid-sentence and read as a
  glitch. It now has a few px strip of its own below the hint.

**Dropped deliberately:** the guide icon's linger glow. Its target is now the
caret, which already breathes on dwell — two attention signals competing on
one control. Say if you want it back.

116 tests pass.

## 2026-08-22 — calmer pace, quieter affordances (v0.11.0–0.11.1)

Four asks, all shipped and verified live.

**1. Everything reveals at half speed.** Rian: "about twice as slow... the
animations in general are defaulting to too fast." Rather than editing a
dozen durations, the pace now lives in `:root` as `--reveal-ms` (760ms, the
peek), `--reveal-fade` (520ms), `--expand-ms` (1100ms, anything that pushes
the page) and `--expand-fade` (760ms). The peek, the in-flow open, the target
fan-out, the card's radius hand-over and the button stagger (70→130ms) all
read from those — **tuning the whole app's pace is now two lines.**
DELIBERATELY EXCLUDED: the alternatives segment (`--bump-ms`/`--open-ms`).
Those are rian's hand-tuned values from the lab; don't fold them in.

**2. The guide is an icon, not a sentence.** "Show me how — 3 steps" is gone
from both places it appeared, and the peek no longer advertises step counts.
A question mark sits at the end of the row instead — `var(--border)` at rest
(almost invisible), muted on row hover, accent on its own hover. It renders
only when the CURRENT step has a guide, so it appears and disappears as an
item advances. **It occupies the slot the expand caret vacated**, which is
how the row got quieter and kept the real estate.

**3. Linger glow.** Four seconds of hovering an OPEN row blooms the icon
(accent + soft halo) then rests, on a 4.2s loop — the pause is built into the
keyframe percentages, same spirit as the segment's discrete breaths. A
keyframe is correct HERE (decorative loop, colour/shadow only, competes with
no :hover rule) — that is not a contradiction of the transitions-not-keyframes
rule, which is about interruptible positional motion.

**4. Peek gained a double chevron** at its right edge saying it opens further
— `var(--border)`, absolutely positioned OVER the hint's mask fade so it costs
the peek no height, fading in 160ms after the peek starts.

**5. The grey expand caret is gone.** With it went the only keyboard path to
expanding an item, so the label became a real control: `role="button"`,
`tabIndex`, `aria-expanded`, Enter/Space. Also: `hasMore` now means
instruction/detail only (not tutorial) — expansion is about text, the guide
has its own affordance.

Verified live: peek caught mid-slide at 0.75s (the slower pace), double
chevron present, glow bloom captured on the third frame, guide icon opens the
right guide, dead `.pl-how` CSS removed.

## 2026-08-22 — the peek seam, actually root-caused (v0.10.3)

Rian, third report on the same bug, with the detail that cracked it: "it's on
ALL items, just not as visible as it is on the bottom one." He also asked
whether it was his browser. It wasn't.

**Root cause: an unclassed `<div>` wrapped every row** in PunchlistPage (an
anchor target for `?item=` deep links + the highlight flash). Two consequences,
and they map exactly onto what he reported:
1. Each row was the `:last-child` **of its own wrapper**, so `.pl-row:last-child
   .pl-peek` matched EVERY row — every peek got the leaving-the-card treatment
   (1px border + 12px rounded bottom + the -1px bleed). That's "on all items."
2. `.pl-list:has(> .pl-row:last-child…)` never matched at all — the `>`
   combinator was looking through the wrapper — so the card never squared off
   its own corners. That's "worst on the bottom one," where the card's rounded
   corners stacked against the peek's.

**Fix:** the wrapper is gone; `anchorId` and `highlight` are props on ItemCard
and land on the row itself, so both lists (active + done) have identical
structure `.pl-list > .pl-row`. Verified in the live DOM: mid-card peek now
computes `radius 0 / border 0 / left 0`; a true last row computes `radius 12px
/ border 1px / left -1px`; deep-link anchors still resolve on the row.

**Two lessons worth carrying:**
- **A structural CSS rule that keys off `:last-child` or `>` is a contract with
  the DOM shape.** Guard comments now sit BOTH at the CSS rule and at the
  `items.map()` where someone would add a wrapper.
- **I "verified" this fix twice by screenshot and missed it both times** — a
  12px radius, white-on-white, at that zoom, read as fine because it was what I
  expected to see. Rian's eye caught it. For edge/seam work, read the computed
  styles (`getComputedStyle`), don't eyeball a screenshot. Note: probing by
  adding a class via JS is ALSO unreliable here — React re-renders wipe it, and
  reading a transitioned property immediately returns the start value.

## 2026-08-22 — step-by-step tutorials (v0.10.0–0.10.2)

Rian: the last-row peek still looked detached, dwell should peek not expand
(shipped in 0.8.3), and "step by step tutorials for any item where you can
create one."

**The peek bug — the missing half of the seam rule.** 0.8.3 gave the LAST
row's peek the card's border and bottom radius, but left the CARD's own
rounded bottom corners in place — so the card curved in ABOVE the peek and
you saw two rounded shapes stacked. Fixed by having the card hand its
bottom rounding over while its last row peeks
(`.pl-list:has(> .pl-row:last-child.is-peeking:not(.is-open))`). It only
ever showed on a section's last row, which is exactly what rian observed.
**The general lesson:** when surface A extends into surface B, BOTH sides
have to give up their edge, not just the new one.

**Tutorials are now real content, not just an interaction.**
- `tutorial` accepts `{title, intro, steps: [{text, note, image}]}` beside
  the legacy bare string (published specs are immutable — the old shape has
  to render forever, so `_tutorial_payload` normalises BOTH into one client
  shape and the modal branches once).
- Guide prose runs through `render_text`, so a step reads "Enter
  rian@plusroi.com, plusroi@gmail.com and support@bowden.cc" — verified
  live on the demo. Tokens in guide text are validated like any other prose.
- **15 guides authored** (GA4 ×3, Google Ads ×2, Merchant Center, Facebook,
  Mailchimp ×2, Wix ×2, Search Console, GBP, registrar ×2). A test asserts
  every shipped guide has steps, non-empty text, and sits on a CLIENT step —
  guides are for the person doing the task.
- Modal: one step at a time (they're doing the task in another tab and
  coming back), a progress rail you can navigate, note in a quieter voice,
  arrow keys, "Got it" on the last step. The nod advertises length —
  "Show me how — 3 steps".
- Screenshots are `asset://` refs rendering as a labelled frame. **Every
  guide's text was written to stand alone without them** — dropping real
  images in later is pure upside, not a dependency.

**Two motion fixes found by using it:** the progress rail was being squeezed
to a sliver (dialog is a flex column, rail was shrinking — `flex: 0 0 auto`
on the chrome), and the centred dialog re-centred itself whenever a step was
taller, sliding the header under the cursor. It's anchored at `top: 9vh` now
so only the bottom edge moves; verified the dialog's top stayed at 76px
across all 7 GA4 steps.

**New capability the tutorials forced, worth knowing about:** live items pin
an immutable template version, so authoring guides would never have reached
the existing demo. Two guarded operations now exist:
- `POST /api/workflows/sync-seeds` (author-gated) publishes a NEW version of
  any shipped workflow whose file moved on. Never mutates a published spec —
  idempotent, publishes nothing when the file matches.
- `adopt-latest-template` (per item, or per punchlist; team-gated) re-points
  live items forward **only when every step the item has actually visited
  still exists**, so its event history can still replay. Incompatible items
  are REPORTED, never silently skipped. Ran it on keith-barnet-site-v2: all
  9 access workflows moved forward, nothing held, and the demo's story
  (GA4 1/3, the Barnet thread, the attachment) survived intact.

110 tests pass. Still open from before: the three small calls (redundant
detail chevron, "Granted · 0/3" pre-start wording, queue header) — plus
rian's "other thoughts" he hasn't written down yet.

## 2026-08-22 (overnight, later) — comment attachments (v0.9.0)

The discussed-but-unbuilt feature with the most value: **files in comments**
— the actual answer to "how does Keith hand us the brand guide / photos /
screenshots". Shipped and live-verified end to end (a real upload + comment
now sits on the Barnet demo item: the endpoint-list image renders inline).

How it works:
- Paste an image into the drawer's composer (or the new paperclip button) →
  uploads immediately → markdown lands in the draft → Send posts one comment
  carrying its files. Images render inline (click-through to full size);
  PDFs become links.
- **DB-free by design**: files live at `data/uploads/{item_id}/{uuid}.{ext}`
  (the mounted volume — survives deploys), and the comment TEXT carries the
  reference. No migration, nothing to rebuild, visibility re-checked through
  the owning item on every read (404-not-403 discipline preserved).
- Security surface (client-facing upload!): allow-list png/jpg/gif/webp/pdf
  (deliberately NO svg/html — script-capable), server-generated names
  (uuid.ext regex is the traversal guard), magic-byte check against the
  extension, 10 MB cap, 50-files-per-item cap, nosniff + our content-type on
  serving. `tests/test_attachments.py` proves each of these; note in there:
  an encoded-slash traversal never even routes to the handler (lands on the
  SPA shell) — asserted as "no shape but ours yields file bytes".
- md.ts images are restricted to OUR relative `/api/items/…` urls so a
  comment can never make a reader's browser fetch an external tracking URL.
- New dep: python-multipart (requirements.txt).

Next obvious steps when rian wants them: attachments in the item ACT flow
(e.g. "Upload brand guide" primary actually taking the file, not just the
comment channel), thumbnails/lightbox, and delete/cleanup lifecycle.

## 2026-08-22 (overnight) — rian's three calls + review pass (v0.8.3–0.8.5)

Rian went to bed asking for: the inner-container seam bug, dwell→peek /
click→expand, targets that "expand into 3 actions, not tooltips", plus a
general review. All shipped and browser-verified; he'll get the version-watch
refresh bar in the morning.

**v0.8.3 — the three calls:**
- **Seam.** Mid-card the peek is now a borderless square-edged SHEET of the
  card's surface (the row's separator drops while peeking); only the LAST
  row's peek — which leaves the card — carries the card's edge (side borders,
  12px bottom radius, -1px bleed covering the card's own corners). Same rule
  as the button segment; expect it to recur on any surface-extends-surface.
- **Dwell = peek (0.5s, on the label), click = open.** The peek takes
  pointer-events while shown so gliding down onto it keeps it out; clicking
  the peek opens too. Dwell-to-open is gone.
- **Targets.** One chip on the line — "{primary_label} · {done}/{n}" with a
  folded chevron — expanding DOWNWARD into the per-target buttons (grid-rows
  push + 70ms stagger, chevron unfolds). No schema change: label derives
  from primary_label. GA4 on the Keith demo shows 1/3 (I granted
  rian@plusroi.com as the story's first arrival).

**v0.8.4 — bugs found while reviewing:**
- Comment-count chip sits inside the label span, so its click BUBBLED to the
  label's toggle → drawer + row-expand together. stopPropagation fix. Watch
  for this on anything interactive later nested in the label.
- Unmatched SPA paths rendered a silent empty shell (only routes are /,
  /team, /admin, /profile, /punchlists/{id} — I guessed /queue and got
  blankness). Now a small "Not here" view with a home button.

**v0.8.5 — the builder's standard + queue polish:**
- **`notes/authoring-workflows.md`** — the "simple local LLM converts an
  email into a punchlist" doc rian asked for: mental model, 8 golden rules
  (goal titles, verb-first ≤4-word buttons, outs on every client step,
  team-verify before $done, targets fan-out, shared variable NAMES, hints
  never regex, keep it small), exact spec reference, email→punchlist recipe,
  API table, submit checklist. `tests/test_authoring_doc.py` runs the doc's
  own json examples through validate_spec — the doc cannot drift.
- Queue: an opened row no longer shows its label twice (header keeps
  chip + provenance while the ItemCard is open) + aria-expanded.

Mobile pass at 393px: labels wrap cleanly, chips right-aligned, tap=expand
works, drawer usable. Lab (round five) now mirrors ALL shipped designs in the
app's real list-card context; artifact republished; lab.html resynced after
each deploy. 98 tests pass. Memory written: rian's motion language
(~/.claude/projects/-srv-apps-caddie/memory/).

**Open questions for rian:**
- Chevron crowding on target rows: chip-chevron + or-caret + detail-▾ is
  three glyphs. Drop the detail-▾ (label/peek click covers it) or keep for
  keyboard access?
- Chip wording "Granted · 0/3" before anything is done — fine, or want a
  distinct pre-start wording (needs an optional spec field)?
- Queue rows: open-state header now minimal — good enough, or want the whole
  qhead to become the card?

## 2026-08-21 — items: overlay peek, slow push-open (v0.8.2)

Rian: "I don't like that it moves all the content as you hover... can it grow
over top of the item below instead of pushing it down. Then on click or dwell,
expand the full amount and push the content down, but slowly."

Implemented exactly as two different mechanisms, which is the point:
- **Peek = OUT of flow.** `.pl-peek` is absolutely positioned at `top:100%`,
  opaque surface + shadow + the row's own `border-bottom` going transparent
  while hovered (so it reads as the row continuing, not a slab with a line
  through it) and `z-index` raised so it paints over the row beneath. Layout
  cost: zero. Hovering down a list no longer moves anything.
- **Open = IN flow, slow.** `.pl-full` animates `grid-template-rows: 0fr→1fr`
  (the auto-height trick) over 560ms, so it genuinely pushes the rest down but
  in one continuous motion.
- **The dwell is bound to the LABEL, never the actions** — resting over a
  button must not spring the row open under a cursor that was reaching for it.
  Label also takes the click; chevron still toggles.

**Worth feeling out (found while testing):** dwell-open + click-toggle can
read oddly — if the 1.5s dwell opened the row while you were just reading,
your next click on the label CLOSES it, which feels like the click failed.
Options if it annoys: lengthen the dwell (~2.5s), or drop dwell-open and keep
click-only. Dial is `OPEN_DWELL` in ItemCard.tsx.

Lab's row-grow section rewritten to match (overlay peek + slow push), dwell
moved to the label there too.

## 2026-08-21 (latest) — the jolts diagnosed and fixed (v0.8.1)

Rian reported two bugs plus two wants. Both bugs had ONE cause, worth
remembering:

**The bug (a CSS specificity trap).** Breathing was a `@keyframes` animation.
Its selector `.pl-reveal.is-revealed.is-breathing .pl-revealseg` scores
(0,4,0); the hover rule `.pl-revealseg:hover` scores (0,2,0). So the
`animation: none` inside the hover rule NEVER applied — the breath kept
running while hovered, snapping the segment out mid-cycle ("inconsistent
pop-out") and dragging it home on its next beat ("jolts back in on one pixel
of movement"). Keyframes also snap rather than interpolate when interrupted.

**The fix (a rule worth keeping).** Every movement is now a TRANSITION, never
an animation — transitions interpolate from wherever the element currently
sits, so interrupting mid-flight is seamless — plus an explicit state machine
in ItemCard (`rest | bump | swell | open`, timers in a ref, state mirrored in
a ref because breath callbacks fire outside React's render). The segment holds
open until the cursor genuinely leaves the SEGMENT, and returns at the open
pace (`is-engaged`) rather than the slow bump pace.

**Also shipped:** one breath then a 3s PAUSE then another (not continuous);
the chevron folds -90° while tucked and unfolds upright on the open glide;
rian's tuned numbers as defaults (1000ms rise / 16% bump / 3s dwell / 3s
breath / 8% swell / 700ms open, no overshoot).

Lab round four adds sliders for the pause and the chevron angle, and carries
rian's settings as the "Yours" preset.

## 2026-08-21 (late) — the choreography lands (v0.8.0)

Rian's third-round animation notes, implemented and shipped:

- **The seam was the real fix.** The segment read as a separate object because
  the button kept its rounded right corners. Now, the moment the bump appears,
  the button's right corners square off and an inset hairline
  (rgba(255,255,255,0.45)) draws exactly at the junction — the two read as ONE
  control, like the old static split button.
- **Choreography:** row hover -> bump rises over 700ms (slow, calm, no longer
  the jittery A4 pop) -> 2s of resting -> the bump breathes (3s cycle, ~12%
  swell) -> hovering the bump glides it fully open over 520ms with
  cubic-bezier(0.34, 1.5, 0.64, 1), a small overshoot that settles back.
  Button hover no longer opens it fully — only the bump does (rian's ask).
- **Lab round three** is now ONE tunable choreography: live sliders for every
  parameter (bump rise/size, breathe delay/length/swell, open glide,
  overshoot), four presets (Calm shipped / Cute / Quicker / Whisper), a CSS
  readout to copy exact values back, and hold-state pins. Row-grow and
  targets-split sections retained unchanged.

**Still rian's call:** whether to move from Calm to Cute (a one-variable
change; the readout gives the numbers), and whether to adopt targets-split
for 3+ target rows.

## 2026-08-21 (night) — lab round two + the reveal ships (v0.7.2)

Rian's animation feedback, iterated in the lab and then shipped:

- **Lab rebuilt** (same artifact URL): the reveal family A1-A4 (two-stage
  slide / bump+dwell soft / bump+dwell cute-overshoot / breathing sliver),
  all transform-only through reserved runway so buttons never move; pinnable
  states (Rest/Sliver/Full) for touch devices; row-grow opening on click or
  1.2s dwell with the tutorial nod inside; collapsed-targets v2 where the
  counted button splits into three staggered actions and folds itself when
  all are done. Old button variants removed per rian.
- **Shipped to the app** (rian's message specified A1's mechanic): sliver on
  row hover, full glide on button hover / 0.7s dwell / sliver hover; 450ms
  soft easing; touch devices get the segment always out; the pulse is
  retired (superseded). Verified live: sliver + hint on the Decisions row,
  clean resting rows everywhere.
- **Browser-verification learning:** synthetic CDP hover/clicks cannot cross
  the artifact viewer's iframe — hover-testing needs a first-party page.
  Workaround that stuck: `docker cp` the lab into the app's static dir →
  https://punchlist.bowden.works/lab.html (hover-true; wiped on each deploy,
  resync with docker cp).

**Rian's picks still open:** easing temperature (soft A2 vs cute A3 — one
CSS variable swap); adopt collapsed-targets-split for 3+ target rows (my
favorite; changes info hierarchy so his call); A4's breathing sliver (extra
alive) vs A1's still sliver (shipped).

## 2026-08-21 (evening) — the affordance lab + polish loop (v0.7.1)

Rian's dinner-time iteration list, all landed and browser-verified in three
passes on keith-barnet-site-v2:

- **Or-Affordance Lab** (artifact "Or-Affordance Lab"): six interactive
  variants + peeks + tutorial nods, with a written verdict. Adopted: V2
  (caret FUSED into the action button — one visual unit, no word), solo caret
  chip after target groups, and the HESITATION PULSE (dwell ~1s on an action
  without clicking -> the caret breathes once; per-item, once).
- **Hover hint peek** (desktop-only, hover:hover): rows grow a fading one-line
  preview — field names, or the instruction's first line; "step-by-step guide
  inside" appended when a tutorial exists. Iteration fix: target rows preview
  their INSTRUCTION (the emails were appearing three times on one row).
- **Tutorial nod**: "Show me how" (? icon) inside the act area + the expand ->
  a How-to modal; graceful "screenshots on their way" placeholder until
  bw-guide-shot content is authored. The INTERACTION ships before the content.
- **Click-away** closes the or-menu, the bell panel, and EMPTY composers/act
  areas (typed text never discarded).
- **The real Barnet thread simulated** on the Keith list: Keith's forward to
  Lester = email step done (item now waiting_on_team at confirm_access);
  rian's six technical questions distilled into a comment on the item. The
  email chaos now has a punchlist-shaped mirror to compare against.

Open design question for rian (in the lab's verdict): adopt collapsed-targets
("Granted 0/3" + hover pills) for 3+ target rows? Not implemented yet.

## 2026-08-21 (later) — comments drawer + version watch (v0.6.1)

- **Conversation moved into a side DRAWER** (review's notes-sheet translated):
  `CommentsDrawer.tsx` + `GET /api/items/{id}/comments`; rows carry only a
  comment-count chip; "Add a comment" opens the drawer. Review's design
  language recorded in 04 as the kit's visual reference; queue-as-review-table
  noted in plan §10a for later.
- **VersionWatch**: footer shows the running version; a stale tab gets a
  "Punchlist was updated — Refresh" bar within 60s of a deploy. (Answers
  rian's "still seeing old labels": his tab predated the deploys, and old
  punchlists correctly pin old template versions — keith-barnet-site-v2 has
  the new ones.)
- **Invites confirmed out of the box** (rian was right): kit ships
  POST /accounts/invite AND per-instance /instances/{iid}/invite (member +
  grant + notify_added for existing accounts). Nothing to build; only the
  end-to-end email dry run remains (roadmap joint gate).

Next: comment attachments (image paste, review-style), AI-builder authoring
doc, interaction-kit extraction into caddie.

## 2026-08-21 — comments+bell, the "or" chip, sections, button register (v0.6.0)

All four of rian's feedback points, live:

- **Comments + notifications** (review-modelled; the Interaction Standard's
  first implementation, extraction source for the shared kit):
  `services/interaction.py` — one thread per item; act() bridges flag/reply
  messages into it so conversation is ONE stream; server-side @mentions; turn
  notifications to the side whose court the ball landed in; dedupe-keyed;
  never-notify-self. `Bell.tsx`: idle/unread/needs-you, 30s poll
  (hidden-tab aware), deep links land scrolled to the item (?item= highlight).
  Migration 0003. 97 tests.
- **The "or" chip** replaces the faint "...": attached after the action, menu
  headed "Can't do this right now?" + Something else… + Add a comment +
  History(team). Non-actionable rows keep a quiet "..." with comment/history.
- **Button register**: label pass over all 16 seeds ("Upload brand guide",
  "API set up", "Submit ID", "Found property access"...). Rule in agents.md.
- **Sections**: item.section (set-supplied) rendered as quiet uppercase group
  headers, order-preserving. website_onboarding v2 carries the email's own
  headings. Organization intent (tags/search/priority: designed, unbuilt)
  in plan §10a.
- Archive route (PATCH state). Old demos archived;
  **punchlists/keith-barnet-site-v2** is the current showpiece (democlient
  granted). Workflows republished (new versions) via the API.

**Known quirks:** needs-you-first sorting can split a section into two groups
(e.g. Billing's team-owned first step sinks below client items) — right for
clients, debatable for the team view; revisit with a team-view sort toggle.
Attachments (screenshots in comments) still absent — next interaction-kit
step. `bw_accounts._state` peeked in interaction.internal-known-mentions —
acceptable but flag for kit extraction cleanliness.

**Next:** invites wiring (real client entry + notify_added), attachments,
AI-builder authoring doc (the "standards doc a simple local LLM can use"),
then interaction-kit extraction into caddie + the tool contract (M3).

## 2026-08-20 (night) — M2 sets + the Keith email converted (v0.5.0)

Rian pasted a real onboarding email ("the problem I'm trying to solve") and
asked to proceed. Both done:

- **Sets shipped** (`services/sets.py`, `POST /api/punchlists/{id}/set-runs`,
  publish + library endpoints, append-only set seeds, 5 new tests — 91 total).
  Fill shared variables once -> every item lands in order; each item takes only
  the variables its template declares.
- **The email is now 14 new seed workflows + the 16-item `website_onboarding`
  set** — every explanation became an instruction behind the chevron, every
  predictable back-and-forth became an alternative (e.g. "I'll export a CSV
  myself instead", "We don't have Merchant Center", "I don't know who our
  registrar is"). Live demo: **punchlists/keith-barnet-site-demo** built with
  one variable fill (the three real access emails). democlient is granted on
  it for View As.
- **Manager Add panel**: From a set / Single workflow / Quick item (label +
  button + optional team-confirm, inline spec, never enters the library).
- riantest removed (it was rian).

**Gaps the real email exposed (log, don't build yet):**
1. No file upload on items — brand guide/photos use link fields for now; the
   interaction kit's attachments (M2 extraction) likely covers it.
2. No sections/priority grouping on a punchlist (the email had
   Critical/Nice-to-have) — ordering carries priority today; "(optional)" is
   suffixed into two labels. Candidate: an `optional` flag or section headers.
3. Info-only preamble ("we'll work while you're away") has no punchlist home —
  that's caddie's stage prose, correctly out of scope here.
4. Three-target rows (GA4/Merchant/GBP) render three big buttons — the
   heaviest rows in an otherwise one-line list. Candidate compaction: collapse
   to "Granted 1/3" until tapped.
5. All-accent buttons make a 16-item list shouty — consider ghost style for
   secondary rows. Taste call for rian.

**Next:** invites wiring (send a real client in via BW invite + notify_added),
the authoring/validate CLI docs for AI builders, interaction-kit extraction
(threads/bell replacing the message embryo). Then M3 caddie contract.
## 2026-08-20 (evening) — compaction + goal-labels + full View As walkthrough (v0.4.1)

Rian's feedback applied, then everything verified in-browser from BOTH sides
using View As (act mode as democlient, read-only re-check):

- **Checklist compaction:** one line per item in one container; compact action
  buttons on the row; fields open on demand; History into the "..." menu;
  instruction/detail/tutorial behind the chevron.
- **The goal-label rule:** spec title = the item's LABEL (goal-phrased,
  {{variables}} render, fields forbidden); step `headline` renamed
  `instruction` (legacy alias accepted forever — published specs immutable);
  seeds rewritten ("Give us access to Google Ads"; property-level fallback
  button is "Added at the property level" under the unchanged GA4 label).
  Published live as ga4 v2 / ads v3.
- **Personas corrected in the plan:** two kinds of people (internal/client);
  team/manager/builder are HATS. Prod now has an `internal` level carrying all
  three hat permissions, a `democlient` member granted only on
  "Client demo — Acme", and that punchlist mid-flow for rian to poke.
- **Walkthrough verified:** silent-ish SSO, home cards, per-target Granted with
  checkoff, fields-on-demand ID submit, struck-through "With us" + header count
  drop, "Having trouble" composer -> "We're on it" chip + accent stripe ->
  queue (attention pinned) -> reply -> flag cleared, conditional unfold,
  team advance, Done bucket. Two bugs found live and fixed in 0.4.1 (queue
  showed raw {{tokens}}; the "..." menu clipped under the container edge).

**Known polish list (not blocking):**
- Team viewer on a punchlist sees client-count copy ("Nothing waiting on you")
  even with a team action visible — header should adapt for team viewers.
- A done item hides its message trail (messages render for the current step
  only) — the team reply democlient never read is only in History. Consider
  showing last messages in a done item's expand.
- The pop-out pulse is untestable via CDP screenshots (1.6s vs 30s latency) —
  verify on a phone during the gate walk. In read-only View As the seen-write
  is (correctly) blocked, so pop-outs repeat in that mode; harmless.
- Stray `riantest` member exists in prod (pre-walkthrough origin unclear) —
  rian: keep or remove via People.

## 2026-08-20 (later still) — frontend live (v0.3.1); M1 ready for the phone gate

The full client + team UI is deployed and was driven END TO END in a real
browser: SSO sign-in (one click, zero typing), home cards with waiting counts,
the item anatomy (interpolated headline, per-target Granted buttons, the quiet
"..." menu with all alternatives + Other, chevron expand with detail/tutorial
slot), the GA4 "no admin option" conditional unfold live, field validation
(bad Ads ID refused; the raw-regex leak found + fixed via FieldDef.hint,
shipped as google_ads_access v2 through the real publish API), team-step
advancement, and the Queue (correctly empty once both items sat on client
steps).

Landed: api.ts (typed client, hand-kept — declared in agents.md), md.ts,
ItemCard.tsx, PunchlistPage.tsx (seen-then-popout ordering), TeamQueue.tsx,
App.tsx wiring (Queue nav, punchlist home cards), styles (~230 lines,
pack-token-driven, reduced-motion-aware pop-out). 86 tests green.

**M1 gate remains (rian's walk, per plan §10):** rian + a fresh test client
account on PHONES — invite → land on the board → per-target GA4 grant → a
"Having trouble" round trip with a visible reply → the conditional unfold →
a team jump-back → the confirmed pop-out on next visit. The client-perceived
struck-through/pop-out states are engine-tested but not yet seen by a real
client-role session (rian is team, so his browser renders team surfaces).

In prod: "Demo walkthrough" punchlist (mid-flow items for the gate walk) and
"Smoke test (safe to archive)". Next build chunk after the gate: M2 —
sets/invites/authoring + the interaction-kit extraction.

## 2026-08-20 (later) — the engine is live (v0.2.0 deployed)

Backend M1 is functionally complete and verified on prod Postgres end to end
(live smoke: create punchlist → ga4 item → per-target grants → team queue →
verify → done; validate dry-run returns pointed errors).

Landed: `app/schemas.py` (WorkflowSpec — all errors at once, near-match
suggestions), `services/{items,workflows,punchlists,rendering}.py` (event-
sourced engine + replay oracle, library with versioned immutable publishes,
kit-coupled punchlist create), rewritten `routers/app_routes.py` (full API,
404-over-403, persona permissions items.act_team/items.compose/
workflows.author registered with the kit), both seed workflows, migration
0002 (trigger ::jsonb fix — found by live exercise, not tests). 86 passed /
4 pack-skips.

**Next: the frontend** — client punchlist view (item anatomy §2: per-target
buttons, subtle alternatives + Other, expand + tutorial, struck-through
waiting-on-us, confirmed pop-out) and the team queue view. Then M1's phone
gate. A "smoke-test-safe-to-archive" punchlist exists in prod from the smoke —
archive or delete when a delete path exists.

## 2026-08-20 — created + Postgres foundation (v0.1.1 code, 0.1.0 deployed)

App created per the caddie runbook (`/srv/apps/caddie/.logs/planning/05-...`),
BW client registered, conformance 62/62. Then the M1 foundation: Postgres
sidecar + managed kit store + full domain schema + migration 0001 (verified on
SQLite and live Postgres — trigger + seeds confirmed via psql).

**Next (M1 continues):** `WorkflowSpec` validation (plan §6, pointed errors),
the engine service (instantiate item / act / derive status, per-target,
alternatives incl. Other, flags), routes replacing the sample `/api/instances`,
the two seed workflows, then the client view + team queue. Deploy will carry
0.2.0 and confirm.

**Learnings already fed back** to caddie's 05 runbook: .app.env perms race,
star-import drops _fixtures, the managed-store swap recipe.

> Append-only, **newest first**. What the last session did, what the next should
> pick up, what is blocked. Read this second (after `brief.md`) when orienting.
> Clear-after-read: once an entry is acted on it can be trimmed.
