---
type: plan
status: proposed — needs rian's read before building
---

# Action controls — "a form that saves as we go"

## The problem, in rian's words

> "They are all buttons right now, and they still don't always make sense on
> their own. eg '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 that when they open it… which one do they
> click?"
>
> "I don't want it to feel like a form, but maybe we think of it more that
> way. A form that saves as we go. People are not submitting 'complete' 12
> times, they are filling in a kind of form that feels like a todo list."

Three failures behind that:

1. **The action hides its own meaning.** A button whose label only makes sense
   after you click it has failed. "Submit choice" is a promise to reveal
   something later.
2. **Submit-then-submit.** Tapping the row's button opens a panel with a
   second, near-identically-named button. Nobody knows which is real.
3. **Everything is a button** even when the answer is a yes, a checkmark, an
   ID, or a file — so every item costs the same interaction no matter how
   trivial it is.

## Every item has a checkbox, and it is DERIVED

The list is a checklist again — a checkbox sits left of every label — but
**you never tick it. Doing the thing ticks it.** Each control type declares
what "done" means for it, and the checkbox is a read-out of that:

**The division of labour, stated plainly:** the **checkbox marks it done**;
the **control on the right, if there is one, is for options and inputs**.
A control that only says "I did it" is saying what the checkbox already says,
so it should not exist. Many items are a checkbox, a label and its ⋯ —
that is now the simplest and probably the most common shape.

**`manual` is therefore the norm, not the exception.** An earlier note here
said manual ticks should stay rare because "a manual tick is a claim with
nothing behind it." That was wrong once the button went away: for an item
whose whole answer is a claim, the checkbox IS the claim, and there was never
anything behind the button either. Deriving still applies wherever a control
produces a real signal (a value, a selection, a count, a terminal state).

| type | what ticks it |
|---|---|
| *(no control)* | the client ticks it — the item is a claim |
| choices | making a selection |
| input | a value that passes the step's rule (e.g. 10 digits) |
| upload | a file attached |
| linkcheck→link | **manual** — see below |
| status | reaching the terminal state |
| combo | a value typed OR an option picked |
| counter (targets / sub-items) | the count reaching full |
| branch (upload-or-notes) | either path finishing — and for the notes path, every field answered **or deliberately skipped** |

Consequences worth holding on to:

- **A skip is an answer.** "No specific preference" completes its field the
  same as typing does. A form you can finish by admitting you have nothing to
  say is a form people finish.
- **Controls stop carrying their own checkboxes.** The check type became a
  plain button and linkcheck went back to a plain link, because the row's
  checkbox is now the state. Two checkboxes on one row would be two answers
  to one question.
- **Progress moved next to the checkbox**, not inside the control — the
  count and the tick both answer "where is this up to", so they belong
  together.
- **A type that CAN derive must.** Manual is right for claims; it is wrong
  wherever a control produces a real signal.
- **You cannot tick a derived checkbox, but you can always UNTICK one** — and
  unticking means undoing whatever ticked it, not just clearing the tick.
  Every control in the row goes back to unanswered. Ticking is earned;
  untucking is a right.
- **Every answer is retractable.** Clicking the choice you already made takes
  it back. An answer you cannot retract is one people hesitate to give, and
  hesitation is the thing this whole design is trying to remove.
- **A two-option toggle carries no ⋯.** With both answers in front of you, a
  menu offering "more options" is offering nothing.
- **Unanswered is an OUTLINE; answering is what fills it.** A filled accent
  means "answered" everywhere else in the system, so an unanswered control
  must not wear it — and two filled halves read as two buttons rather than
  one question. Answered, the choice fills with accent and the road not taken
  drops to the chip tone.

**Naming trap worth avoiding in the app:** a control's `options` (its
answers — Yes/No, the combo's presets) and its `alts` (other ways to answer
the item, on the ⌄) must be DIFFERENT keys. Sharing one rendered a yes/no
toggle's answers as menu items.
- **Completion is re-asked after every interaction** from one function, so a
  new control type cannot forget to report.

## The shape of the answer

**A step declares what KIND of control answers it.** The control renders on
the row, in the state the answer is actually in, and saves the moment it is
used. `choices` (shipped) is the first of these; the rest follow the same
rule.

A control is **always** paired with the alternatives caret, so every one of
them keeps "I'll do it later", "Having trouble", "Something else…", the
comment thread and the guide. That never changes per type.

### The control types

| Type | Renders as | Answers | Rian's examples |
|---|---|---|---|
| `button` | one button (today's `primary`) | a claim ("Granted") | the current default |
| `choices` ✅ | inline segmented toggle | one of 2–4 options | age verification |
| `check` | a button-shaped control with a checkbox inside; stays checked | "I did it" | Facebook portfolio, Barnet intro, Search Console, GBP |
| `input` | an inline field, saves on blur/Enter | a value | Google Ads customer ID |
| `upload` | a button with a file icon; becomes the file once uploaded | a file | brand guide, zip of photos |
| `linkcheck` | ONE control: checkbox left, ↗ right, label changes with state | go somewhere, then say you're done | "Upload to Google Drive" → "Uploaded" |
| `status` | a dropdown of named states; the LAST one is the checkbox | where a thing is up to | invoice: waiting → received → paid |
| `combo` | a field with the control's own options fused on (⌄) | a value, typed OR picked | hosting: type your host, or pick ours |

### Two structural pieces these need

**A. Per-target controls.** `targets` already fans a step over an email list.
Today each target is a button. It should take a control type too, so GA4
becomes three *checkboxes* — "Granted rian@plusroi.com (admin)" — each with
its own caret ("later", "can't find her"). Rian also wants the collapsed chip
to **peek downward** revealing the individual addresses before you commit to
expanding, which the existing peek machinery already does.

**B. Sub-items within one step.** The add-ons case: "Review add-ons (0/2)"
collapses, expands to a yes/no beside *each* add-on, and the row's own action
becomes a submit at the bottom-right only if one is still needed. This is
`targets` generalised from "a list of emails" to "a list of questions", and
it is the only genuinely new engine concept in this document. Everything else
is a rendering change over the existing act/event model.

## Why this is not a form

The distinction rian is drawing matters. A form is a batch: fill everything,
then submit once, and until you submit nothing is real. What he wants is the
opposite — **each control commits on use**, so the list is a record of what
you've done rather than a draft of what you're about to do. That is why the
answer must stay visible on the row afterwards (shipped in 0.13.0): if
answering makes the control disappear, it reads as a submit, not a save.

## The lab (built 2026-08-21, before any implementation)

`scratchpad/action-controls-lab.html`, also served at
`punchlist.bowden.works/controls.html` (wiped on deploy — `docker cp` to
resync). All ten types live and clickable.

**What building it settled:** the types are not ten widgets, they are **one
chassis with swappable bodies**. In the lab that is literally the code — a
`BODY` map of render functions and a single `group()` that wraps any body in
the chassis plus the caret. Adding a type is adding one entry to `BODY`, which
is the flexibility rian asked for. The app implementation should keep that
shape rather than growing a branch per type in ItemCard.

Shared by construction, never re-specified per type: 31px height, 8px radius,
13/700 type, the accent fill, the caret fused on with the seam hairline, and
the alternatives menu.

**⋯ vs ⌄ — the glyph rule (rian, 2026-08-21).** There were two dropdown-ish
affordances competing: the alternatives menu fused to a control, and the
chevron that unfolds a counter's sub-rows. Both were chevrons, so neither
meant anything. Now:

> **⋯ opens a MENU of other options. ⌄ EXPANDS this thing in place.**
> Never mixed, anywhere.

So the alternatives caret is three dots, and the chevron belongs only to
things that unfold (the counter, and the peek's pull-down handle). In the lab
the dots also **tuck behind the control at rest and slide out on row hover**,
spreading apart as they emerge — the same runway trick as the app's segment,
and the dots' spread replaces the chevron's unfold as the little flourish.
**The app still has the old collision** and needs this change; note that
swapping its segment glyph retires the chevron-unfold animation rian liked,
which is why the dots spread instead.

**A `ghost` variant** emerged and earns its place: a secondary control that
sits beside a filled one (link + check) or repeats down a sub-list (three
target checks) without three of them shouting as loudly as one primary.

**Type 10 became a combobox.** The first attempt made a field reveal a sub-row
on focus, which I flagged as too clever; rian's answer was better than my
proposed cut — **give the field the control's own ⌄ and put the standing
options behind it.** Most clients type the host they already use; the preset
is there for the ones who want it. Both paths — typing or picking — settle
into the SAME answered pill, so the control never looks like two different
things depending on how you got there, and the pencil reopens it either way.

That "one settled shape, however you got there" rule is worth holding for any
control with more than one input path.

## Rule: PORT the choreography, never re-derive it

I rebuilt the lab's hover/dwell behaviour from scratch instead of porting the
app's, and flattened months of rian's tuning without noticing. What I lost, in
one line: **row hover only BUMPS the menu out 16% — full open happens when you
hover the bump itself.** I had it going fully out on row hover, which throws
away the entire two-stage reveal along with the breathing, the pauses, and the
engaged return pace.

The tuned values live in the app's `styles.css` (`--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)) and the four-state machine
in `ItemCard.tsx` (`rest → bump → swell → open`, `BREATHE_AFTER`/`BREATH_MS`/
`BREATH_GAP` all 3000ms, plus `is-breathing` for half-breath pace and
`is-engaged` so the return matches the opening). **Copy them verbatim into any
new surface.** They are the single most iterated-on thing in this project.

## Alternative PATHS vs. outs (rian, 2026-08-21)

Type 5 surfaced a distinction the menu needs to make. "No guide — I'll give
you notes" is **not** an excuse like "I'll do it later"; it is a different road
to the same destination. So the ⋯ menu has two regions:

```
No guide — I'll give you notes        ← alternative PATHS (answer differently)
──────────
CAN'T DO THIS RIGHT NOW?              ← outs (defer, flag, ask)
I'll do it later
Having trouble
Something else…
──────────
Show me how  ·  Add a comment         ← help
```

Choosing a path **swaps the control** (upload → "Add notes") and **swaps what
the item expands into** (the instruction → a form). Switching back restores
both. Two properties fall out that the app must keep:

- **The swap is symmetric.** The return path is the same operation with the
  other argument, not a special case — the lab does both directions through
  one branch.
- **Controls created at runtime must be re-wired.** The swapped-in control
  gets a fresh caret, so the choreography's state machine has to be
  re-attached to it (`wireCarets(row)` in the lab, idempotent via a
  `data-wired` marker). An app implementation gets this for free from React.

**This is already in the spec.** A path is 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 jump-alternatives
sort above flag-alternatives in the menu.

**Every field carries the same escape hatch.** Not just the item — each field
inside an expanded form has its own ⋯ with a way out: the logo row offers "take
them from our current site", each notes box offers "no specific preference".
Choosing one **replaces the input with a ticked check** ("No preference"), and
**unticking restores exactly what was there**. That symmetry is the rule: an
escape hatch is a toggle, never a one-way door.

**The item's menu lives on the CHECKBOX**, not on the control — its outs
belong beside the thing that says whether the item is done, not attached to
whichever control happens to answer it. The control on the right is bare.

**And it GROWS rather than slides.** The tab starts exactly the checkbox's
size, hidden behind it, so at rest the row shows a perfectly ordinary
checkbox and nothing else. It then grows rightwards **over the label**, so
the row reserves no gutter and nothing shifts.

**Reveal by CLIPPING, not by resizing.** The tab is always its full width and
is revealed with `clip-path: inset(… round 6px)`. Animating `width` re-lays
the element out every frame and rounds to whole pixels — which over a 2.7px
breath produced a visible stepping, a "low frame rate look". A clip
interpolates sub-pixel and never touches layout.

**It also needs its own pace.** This tab travels a few px where the others
travel a few dozen, so the shared 1000ms/3000ms read as sluggish on it:
520ms peek, 1500ms breath, 2200ms gap, and the 700ms open kept as-is. The
state machine reads per-instance timings off the element, so a component can
be quicker without forking the machine.

**It peeks for the CHECKBOX, not the row.** A row is a big target and this
sits in the reading path; peeking on row hover meant it surfaced whenever the
cursor crossed the label.

Growing is the load-bearing choice: a sliding tab is a fixed width, so a
second icon means re-tuning the travel. A growing one just gets wider —
`--box-icons: 2` and every state follows, because bump and swell are
*fractions of the growth* (16% / 24%, the same proportions as everywhere
else). Rian asked for this specifically, wanting room for a second icon later.

Sub-row and field controls keep their own ⋯: those are options for *that*
control, not for the item.

**One choreography, three axes.** A button-shaped control anywhere — including
inside a form — keeps the sideways caret. A **full-width field** has no room
beside it, so the same tab tucks behind its top edge and glides **UP out of
the top-right corner**: identical colour, shape, states, timings and seam
rule, only the axis differs. The vertical distances are derived in CSS from
the horizontal ones (`calc(var(--bump-at) * -1)`) so a single tuning drives
both and they can never drift apart. Measured: 100% → 84% → 76% → 0, mirrored
exactly.

(An earlier attempt made a field's menu a static ghost button at the label
line. Rian: "too far out of consistency." He was right — the rule is that the
choreography is universal and only the geometry adapts. Three orientations
now prove it: right off a button, up out of a field, left out of a checkbox.)

**Geometry rule for all three: the runway must equal the tab's width.** At rest
the tab is pushed exactly its own width behind the anchor, so any extra
padding is a permanently visible sliver.

**The dots roll out.** Bunched and small while tucked, they unfurl one at a
time on the open — staggered starts, each overshooting a hair before settling.
Subtle enough to register as craft rather than motion.

**Naming: 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.

**Switching path opens the form in the same motion.** Choosing "Provide brand
notes" both swaps the control and expands the item — asking for a second click
to see what you just chose is a wasted step. The control it swaps to is a
**check** ("Notes added"), not a button: the form saves as you go, so the only
thing left to say is that you're finished.

**Placeholders are the teaching surface.** A client without a brand guide
usually doesn't know what we need either, so each field's placeholder carries
a worked example and explicitly blesses "no idea, you pick" as an answer. An
empty box with a terse label gets an empty box back.

## Sequencing

1. `check` — appears in the most examples, smallest build.
2. `input` — the "nothing to click, just fill it in" case.
3. Per-target controls — turns GA4/Merchant/GBP into checklists.
4. `upload` — needs the attachment store wired to items rather than comments.
5. `link` + `status` — small, once the pattern is established.
6. Sub-items — design properly; it is the one real engine change.

**A control that both travels and records is ONE control, not two.** The first
attempt put a link button beside a ghost check — rian merged them: **state on
the left, destination on the right, and the label between them changes with the
state** ("Upload to Google Drive" → "Uploaded"). Two hit zones in one object:
the box records, everything else travels. The ↗ stays at low opacity because
it is a signpost, not a second button competing for attention; the box scales
slightly on hover so its separate target is discoverable.

## Action first, explanation after

**Inside an expanded item, the actions come first and the prose goes
underneath.** Rian: "focus on the action first and put the explanations and
text and stuff further down in general." You opened the item to do the thing,
not to read about it.

**Two sub-layouts, chosen by what the text IS:**

- **Inline** — when the control can carry its own identity ("Granted —
  someone@example.com") there is no caption to place, so the controls flow
  together on one line and stay compact. This is the targets case.
- **Split** — when the text is the QUESTION being answered ("Promotions
  schedule — $450"), it leads on the left and the control answers on the
  right. This is the sub-items case.

A first attempt stacked every sub-row control-above-caption. That is right
only when there IS a caption; for targets it produced "Granted / Granted /
Granted" down the page with the addresses orphaned below. Put the identity in
the control instead.

Sub-row controls take the **standard fill**, not the ghost variant. (Ghost
survives only where a control genuinely is secondary; it appears nowhere in
the lab now.)

**Status is a dropdown, not a track.** An inline track dies past three states
and several of these will run to five or six. Two rules came with it:
- **The first state is written 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.
- **The last state is terminal, so the control puts on its checkbox.**
  Reaching the end of the list is what being done means; there is no separate
  "and now mark it complete". Unticking steps back one state.

## Two traps the expanding container sets

Both bit in the lab and both will bite the app, because the app uses the same
`grid-template-rows: 0fr→1fr` expansion.

**1. The clip.** That animation needs `overflow: hidden` on the inner box —
which also clips any popover inside it. A field's menu was being *cut off*,
not painted behind. Release the clip once the row has finished opening, and
re-apply it the instant it starts to close. **Use a timer, not
`transitionend`** — that event never fires if the element isn't composited (a
backgrounded or stalled tab), which would leave the clip on forever and
silently break every menu inside an expanded item.

**2. The trapped stacking context.** A positioned wrapper WITH a `z-index`
becomes a stacking context, capping everything inside it at the wrapper's
level. The vertical tab's wrapper needs the tab BELOW its field (so it can
hide) and the menu ABOVE everything — impossible from inside one context.
Leave the wrapper at `z-index: auto` and let the tab and the menu carry their
own.

## Rules to hold while building

- **A control's label must make sense with the item collapsed.** If it only
  makes sense once expanded, the type is wrong.
- **Never two submits for one answer.** If a control needs detail, the detail
  belongs behind a branch (`to` another step), not a second button.
- **Acting must not move the row.** (Learned the hard way in 0.13.1.)
- **The caret stays.** Whatever the control, the outs must be one click away.
- **Every type is spec-declared and validated** with pointed errors, the same
  as `choices` — the authoring standard has to teach it or the AI builder
  can't use it.
