docs(product): spec Phase L L7 — capture ordering, card metadata, and selection styling

Persisted gap-preserving per-bank display order + drag-drop reorder (model change, JSON round-trip + migration), decorative bars.beats/s.ms overlay, tertiary-border selection restyle. Model work separated from draw work; forks flagged (meter source, M9 substrate overlap, drag disambiguation).
This commit is contained in:
2026-07-27 01:11:05 -04:00
parent 9decd25d88
commit e4bcc8f075
2 changed files with 267 additions and 4 deletions
+148
View File
@@ -1643,6 +1643,154 @@ predicate). The `TrackPopupMenu` popup, the `Main_OnCommand` dispatch, and the t
timer + LICE overlay draw are the only DAW-bound pieces; the L1 kit draws. **L5 resolves its
menu/tooltip/action-id specifics against the landed L4 `bank_panel`** (build-time confirmation).
## L7 capture ordering · card metadata · selection styling (ordering model · overlay · tertiary-border selection)
L7 is the **next ungated dock-panel pass over the same `bank_panel` grid** L4L6 built. Unlike
L4/L5 (pure layout over existing actions), **L7 (1) is a persisted-model change** — it adds an
explicit per-sample display order with gap-preserving sparse placement to the persisted bank
state and its JSON round-trip. (2) and (3) are draw-only. **No new capture/placement behavior;**
the "capture ≠ placement" principle is untouched. Drawn through the L1 kit in the DS-2
grey-neutral + three-accent-pastel palette; **no palette/font decision is re-opened.** Ungated
by Phase S; independent of the L3 gate. Sequences AFTER L6.
**Research baseline (dev, confirmed at spec time).** The grid draws in **`BankIndex` insertion
order**: `BankIndex` holds `std::vector<Sample> samples_` in insertion order (`all()`), and
`bank_grid::computeCellRects` tiles exactly one contiguous rect per sample left-to-right,
top-to-bottom — **no explicit ordinal, no sparse slots today.** `Sample` already carries
`lengthSeconds`, `lengthBeats`, and `captureTempo` (BPM at capture) but **no time-signature
field.** The selected cell is drawn (`bank_panel::drawThumbnail`) as
`InteractionState::Active` (accent-fill surface) + an `accent/primary` border + an *inverted*
waveform (`bg/base`), with focus as a `text/primary` double-line ring. Drag today: press on a
selected cell arms a drag; crossing a threshold begins it; leaving the client rect hands off to
OS drag-out (`drag_out::decideGesture`); a drop on a tab / the other region moves (or copies on
Ctrl) the samples to that bank. **There is no same-bank in-grid reorder today.**
### 1. Persisted deterministic order + sparse (gap-preserving) placement
Each bank (and the pool) carries an **explicit, persisted display position per sample** — the
grid no longer derives order from insertion order. Positions are **gap-preserving**: a sample
may occupy a slot that leaves earlier slots empty (an empty first row above an occupied second
row is a valid, persisted state).
**Where the position data lives (the CLAUDE.md constraint).** `bank_model` / `Sample` are stated
**untouched by `bank_book`'s wrapping** (no `bankId` on `Sample`; a bank is a logical grouping
over the shared pool). Display position is a **per-bank display concern**, so it belongs with the
bank's membership, **not on `Sample`** — a copy of a sample into two banks can sit at different
slots. **Recommended carrier: a per-`Bank` ordered position map in `bank_book`** (sample id →
slot), leaving `bank_model` untouched. The exact carrier (ordered id list with gaps vs. explicit
id→slot map) is settled at build; the *contract* below holds either way.
**Ordering contract (deterministic, gap-preserving):**
- **Deterministic:** the grid iterates positions in ascending slot order; ties are impossible
(one sample per slot). The order is fully determined by the persisted position data, not by
insertion order or hash.
- **Insert (new capture):** a new capture takes the **next free slot after the last occupied
slot** (append). It never fills an earlier gap automatically — a gap is a user's deliberate
layout, not a hole to be plugged.
- **Delete / remove / prune:** removing a sample **leaves its slot empty** (does not re-pack) so
every other sample keeps its position. Define at build whether a purely-empty trailing tail is
trimmed for content-height purposes (the recommendation: trim trailing empties for scroll
extent, keep interior gaps).
- **Reorder:** the user drags a card to a target slot within its bank; the pure reorder mutator
moves that sample's position to the target slot, gap-preserving. (Whether a drop onto an
occupied slot displaces/swaps vs. inserts-and-shifts is settled at build — recommendation:
**drop-into-empty-slot places there; drop-onto-occupied inserts before and shifts the tail**,
matching common file-manager reorder.)
**JSON round-trip + migration (load-bearing).** `serialize`/`deserialize` stay lossless
including positions (`deserialize(serialize(x)) == x`). **A pre-L7 project blob has no position
data** → on load it defaults to **current insertion order, densely packed (no gaps)**, so a
project saved before L7 is visually identical on first post-L7 load. This default is the
migration; it is one-way (once re-saved, the position data is authoritative). Pure and
hard-tested — this is a persisted-model change, tested to the same bar as `bank_book`'s existing
round-trip + legacy migration.
**Undo.** A reorder is **one Ctrl-Z** — the actions/shell layer wraps the mutation in a batched
undo point (`Undo_BeginBlock2`/`EndBlock2`), matching every existing bank-verb's undo discipline.
**M9 overlap (flagged — Daniel's awareness).** M9 "slots" (capture-to-slot-N / insert-slot-N,
MIDI-bindable, MPC-style) is **explicitly deferred (Daniel, 2026-07-26).** L7's sparse-slot
model is a **partial overlap:** it builds the *addressable-position substrate* M9 would sit on,
but L7 adds **no** slot-numbered capture/insert actions and **no** MIDI bindings. Building L7
un-defers the *coordinate model* portion of M9, not the *action* portion. If Daniel wants the
substrate to be explicitly M9-shaped (fixed numbered slots vs. a plain gap-preserving ordinal),
that is a fork to settle before the model is built.
### 2. Decorative metadata overlay (bars.beats · s.ms)
Each card overlays, on the waveform, two decorative read-outs of **capture length**:
- **bottom-LEFT:** length in **bars.beats.subdivisions** (musical).
- **bottom-RIGHT:** length in **seconds.milliseconds** (wall-clock).
**Contract:** decorative and **non-interactive** — no hit-test, no hover, no selection role.
Drawn via the L1 kit `text()` in the **micro / value-mono** type class, in `text/dim` (or a
subtle shadowed variant for legibility over the peaks), subordinate to the waveform. **Respects
the speed constraint (no animation).** Pure formatting helpers (below) are unit-tested; only the
kit draw is shell.
**Bars.beats source (FORK F1 — Daniel to confirm).** bars.beats.subdivisions requires a
**tempo + time-signature** reference. `Sample` already carries `captureTempo` (BPM at capture)
and `lengthBeats`, but **no time-signature.** Two options:
- **(recommended) Capture-time stamp:** add `captureTimeSigNum` / `captureTimeSigDenom` to
`Sample` + its JSON round-trip, stamped on the capture path (read the project meter at capture
via the REAPER meter API — verify `TimeMap_GetTimeSigAtTime` / equivalent against the SDK at
build). *Why recommended:* the label is **stable** as the project's tempo/meter later changes;
a bank sample outlives the project state it was captured under, matching the existing
`captureTempo` stamp philosophy. **Cost:** this is a **capture-path write beyond draw work** —
its own flagged checkbox; old samples with no stamp fall back gracefully (blank musical
read-out, or a documented assumed 4/4).
- **(rejected) Live project meter at draw time:** the label would drift under the card as the
project tempo/meter changes, and would be wrong for any sample captured under a different
meter than the project's current one.
**Formatting helpers (pure, tested).** `bars.beats.subdivisions` from `lengthSeconds` +
`captureTempo` + capture-time signature; `seconds.milliseconds` from `lengthSeconds`.
Deterministic; graceful on edge cases (zero length → both read empty/`0`; missing tempo → blank
musical read-out, keep the s.ms read-out).
### 3. Selection styling — tertiary border replaces inversion
A selected card **drops the inverted accent-fill treatment** and instead draws the **normal
cell** (Rest or Hover surface) + an **`accent/tertiary` border** (pastel purple `#C2AAE8`); the
**waveform draws in its normal accent color** (the inverted `bg/base` wave is removed). The four
grid-card interaction states stay **visually distinct and coherent:**
- **Selected:** normal cell surface + **`accent/tertiary` (purple) 1px border**. No fill change,
no wave inversion.
- **Focus (caret):** the existing distinct inner ring — keep it separate from the selection
border so a focused *and* selected card reads both (e.g. purple outer border + a
`text/primary` inner focus ring). Settle the exact inner treatment at build so focus is
legible on top of the selection border.
- **Hover:** the kit Hover surface (unchanged) — a fill-state change, orthogonal to the purple
border, so a hovered selected card still reads as selected.
- **Drag-target slot:** the reorder drop-target highlight (a distinct accent — recommend
`accent/hot` outline on the target slot) must not be confusable with the purple selection
border; spec the exact treatment at build.
### Drag disambiguation (FORK F3 — proposed rule, Daniel to confirm)
In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out.
**Proposed precedence (one clean rule, evaluated live during the drag):**
1. **Pointer leaves the client rect → OS drag-out** (unchanged; `drag_out::decideGesture` wins
first — the existing invariant #4 boundary).
2. **Else drop lands on a tab / the OTHER region's bank → move/copy** (unchanged; Ctrl = copy).
3. **Else drop lands within the SAME bank's own grid → reorder-to-slot** (new).
So: leave-client wins → else other-bank wins → else same-bank-grid = reorder. The precedence is
encoded in a **pure decision helper** (mirror `drag_out::decideGesture`); the shell reads the
live pointer + focused region + client rect and calls it. This keeps the reorder gesture from
ever stealing an intended bank-move or OS-drag, and keeps a same-bank in-grid drag from being
mis-read as a no-op (today a same-bank drop is a no-op; L7 gives it reorder meaning).
### Pure/shell discipline (L7)
Model: the position carrier + gap semantics + reorder mutator + JSON round-trip/migration are
**pure** (in `bank_book`, CTest-covered to the bar of its existing round-trip). Layout: the
sparse-aware slot↔rect math + point→slot hit-test + the drag-disambiguation decision are **pure**
(extend `bank_grid`; mirror `mode_switch` / `drag_out::decideGesture`). Formatting: the
bars.beats and s.ms formatters are **pure**. Shell (DAW-bound): the reorder drag wiring +
drop-target highlight, the capture-time-signature stamp (F1) read on the capture path, and the
kit overlay/selection-border draw. The L1 kit draws; no palette/font decision re-opened.
## The L3 gate + Phase S coordination contract
**L3 (VST editor + embed-strip restyle) is GATED on Phase S landing on dev.** The VST