docs(multi-bank): settle Phase B forks 1-4; draft fork-5 options analysis

Fold Daniel's decisions into CONTEXT.md, PLAN.md, and the product notes:
- Fork 1 (persistence): pool folds into the `banks` blob; legacy
  `bank_index` key retired after a one-way lossless migration.
- Fork 2 (delete): delete drops member index entries; add an `evacuate`
  verb (B1 pure op + B3 action) returning members to the pool. Design the
  orphaned-until-prune window and confirm-on-non-empty-delete guardrail.
- Fork 3 (move): move is the default gesture, copy the deliberate secondary.
- Fork 4 (active bank): active-bank and shown-tab stay distinct; the
  active-bank indicator must be visually unmistakable.

Fork 5 (tab rendering + move affordance) remains open with an options
analysis and pending recommendations. Additive; M0-M11 and Phase D untouched.
This commit is contained in:
2026-07-23 13:06:21 -04:00
parent d9081090fd
commit dcbe5d7bdc
3 changed files with 433 additions and 191 deletions
+79 -55
View File
@@ -369,7 +369,7 @@ arrange; the only change is *which* index the entry lands in.
active bank's `BankIndex`, then adds exactly as today. No capture backend changes;
only the add-target is selected upstream. Activating a bank is a model mutation +
a persist write; it never touches the timeline.
- **Movement moves the index entry, not the file (default).** Moving a sample from
- **Movement moves the index entry, not the file.** Moving a sample from
bank A to bank B is an **index-only** operation: remove the `Sample` from A's
`BankIndex`, add it to B's. The underlying file stays in the project bank folder —
banks are logical groupings over one shared file pool, not separate folders on
@@ -383,18 +383,36 @@ arrange; the only change is *which* index the entry lands in.
**not** enforced: the same hash may exist in the pool and in a named bank
simultaneously (that is the point — copy lets a sample be grouped into "Drums"
while still living in the pool).
- **Move vs. copy are distinct acts.** *Move* removes from source, adds to
destination (one logical sample, regrouped). *Copy* adds to destination and leaves
the source entry intact (same file, two index entries, two banks). Both are
index-only; both share the destination-collapse rule. Copy is what lets a sample
live in the pool *and* a named group at once.
- **Persistence: a new ext-state key, pool migrates in place.** The multi-bank
state serializes to a new key `banks` in the existing `"reasampler"` namespace,
alongside `bank_index`, `view_state`, and `project_guid`. **Migration:** on load,
if a `banks` key is absent but a legacy `bank_index` key is present, the legacy
index becomes the pool's `BankIndex` and the book is `{ pool }` with zero named
banks — a one-way, lossless promotion. (Whether `bank_index` is retired or kept as
the pool's canonical slot is an open fork — see below.)
- **Move vs. copy are distinct acts; move is the default.** *Move* removes from
source, adds to destination (one logical sample, regrouped) — it is the **primary,
low-friction gesture**, so a sample lives in exactly one bank at a time. *Copy* adds
to destination and leaves the source entry intact (same file, two index entries, two
banks) — the **deliberate secondary act** for the "in two places at once" case. Both
are index-only; both share the destination-collapse rule. Under move-as-default the
pool is the default home and staging ground, not a permanent superset: moving a
sample into a named bank takes it out of the pool. (See product notes for the
mental-model reconciliation.)
- **Delete drops members; evacuate returns them.** Deleting a **named** bank drops
its member index entries (files are **not** deleted — file lifecycle stays owned by
the capture/prune path). A separate **evacuate** operation moves all of a bank's
members back to the pool (index-only, same destination-collapse-by-hash as move),
leaving the bank empty. Intended workflow: *evacuate then delete* to keep the
samples, *plain delete* to drop the grouping and its members. A plain delete of a
non-empty bank orphans those members out of every index — their files persist on
disk until prune, referenced by no bank — so the UI **confirms on non-empty delete**
and offers evacuate as the alternative. Evacuate cannot be applied to the pool.
- **Persistence: a new ext-state key; the pool folds in and the legacy key is
retired.** The multi-bank state serializes to a new key `banks` in the existing
`"reasampler"` namespace, alongside `view_state` and `project_guid`. The pool's
index rides *inside* the `banks` blob as bank-zero — persisted identically to any
named bank (one blob, one section, one JSON shape). **Migration:** on load, if a
`banks` key is absent but a legacy `bank_index` key is present, the legacy index is
promoted into the pool inside a freshly-minted `banks` blob and the book is
`{ pool }` with zero named banks — a one-way, lossless promotion. After migration
the `banks` blob is **authoritative**; the legacy `bank_index` key is **retired** (not
written or read back going forward). The one-way retirement trades pre-multi-bank
backward-read compatibility for the clean single-blob shape — an accepted,
forward-only migration consistent with how M4 project state already moves forward.
- **Vertical-split UI, pool on top.** The bank window splits vertically: **pool on
top**, the **named-banks region below** (a tab-page strip, one tab per named bank,
empty when none exist). Two full-height toggles collapse the split: **pool
@@ -409,12 +427,15 @@ arrange; the only change is *which* index the entry lands in.
the relative-path invariant at its `add` boundary — the book adds no new path
handling, because movement is index-only and files never relocate. The invariant
is enforced N times (once per bank) by the exact code that enforces it today.
- **Non-destructive.** Bank create / rename / delete / activate and sample
- **Non-destructive.** Bank create / rename / delete / activate / evacuate and sample
move / copy mutate only index + ext-state; no file is written, moved, or deleted,
and no timeline item is touched. Deleting a **named** bank does not delete its
samples' files (they may be referenced by the pool or another bank via copy);
file lifecycle stays owned by the capture/prune path, not the bank container. See
open fork on what "delete a named bank" does to its member samples.
and no timeline item is touched. Deleting a **named** bank drops its member index
entries but does **not** delete their files; file lifecycle stays owned by the
capture/prune path, not the bank container. A file referenced *only* by the deleted
bank becomes an orphan on disk — present but indexed by no bank — until the
capture/prune path reclaims it. That orphaned-until-prune window is designed, not
accidental; the *evacuate* verb and the confirm-on-non-empty-delete guardrail exist
to keep the user out of it unintentionally.
- **Travels-with-the-.rpp preserved.** The `banks` blob rides the same ext-state
namespace and the same GUID-primary project-identity / Save-As-relocation
machinery as the bank index does today (M4). One shared physical bank folder, one
@@ -429,27 +450,33 @@ Pure (no REAPER types, unit-tested — the mirror of `bank_model` / `view_mode_m
- `bank_book` — ordered bank registry (`{ bank id, display name, ordinal,
BankIndex }`); pool seeded with fixed id + name; create / rename / reorder /
delete named banks (pool-privilege rules enforced here: reject delete/rename of
pool); active-bank id (get/set, defaults to pool); **move** and **copy** a sample
between banks (index-only, destination-collapse observed); query a bank's index;
JSON round-trip of the whole book (banks + per-bank indices + active id +
ordinals) and legacy-`bank_index`→pool migration on parse.
pool; delete drops member index entries); **evacuate** a bank (move every member to
the pool, index-only, destination-collapse observed; pool cannot be evacuated);
active-bank id (get/set, defaults to pool); **move** and **copy** a sample between
banks (index-only, destination-collapse observed); query a bank's index; JSON
round-trip of the whole book (pool-as-bank-zero inside the blob + named banks +
per-bank indices + active id + ordinals) and legacy-`bank_index`→pool migration on
parse (one-way; blob authoritative thereafter).
REAPER-facing:
- `persist` (slice) — serialize/deserialize the book under the `banks` key in
`"reasampler"`; migrate a legacy `bank_index` key into the pool on first load;
reload-on-open and Save-As survival ride the existing M4 machinery. The session
exposes the book the way it exposes the bank today; the active bank's `BankIndex`
is what the capture layer adds to.
`"reasampler"` (pool-as-bank-zero inside the blob; no separate `bank_index` key
going forward); migrate a legacy `bank_index` key into the pool on first load
(one-way; blob authoritative thereafter, legacy key retired); reload-on-open and
Save-As survival ride the existing M4 machinery. The session exposes the book the
way it exposes the bank today; the active bank's `BankIndex` is what the capture
layer adds to.
- `bank_panel` (extension) — the vertical split: pool grid on top, named-banks
tab-page region below; two full-height toggles; the active-bank indicator; the
create / rename / delete / activate affordances; sample move/copy affordance
(drag between regions and/or a "send to bank" menu on selection). Reuses the
existing LICE grid render loop per bank region.
- `actions` (entries) — create bank / rename bank / delete bank; activate bank
(direct + cycle); move selected samples → bank; copy selected samples → bank;
pool/banks full-height toggles. Registered with the `command_id` / `gaccel` /
`hookcommand` pattern; bank-activate + move/copy MIDI-bindable to suit the
capture-heavy workflow.
- `actions` (entries) — create bank / rename bank / delete bank (confirm on
non-empty delete); evacuate bank → pool; activate bank (direct + cycle); move
selected samples → bank; copy selected samples → bank; pool/banks full-height
toggles. Registered with the `command_id` / `gaccel` / `hookcommand` pattern;
bank-activate + move/copy + evacuate MIDI-bindable to suit the capture-heavy
workflow.
## REAPER API surface (verify all signatures)
@@ -474,8 +501,12 @@ verify against the SDK header where they extend existing surfaces:
- **No cross-bank dedup enforcement.** The same hash may exist in multiple banks
(that is what copy is for). Do not add a global dedup that collapses across banks.
- **Pool privileges are inviolable.** No action path may delete or rename the pool,
or leave a project with zero banks. Enforce in the pure rules layer, not just the
UI.
leave a project with zero banks, or **evacuate** the pool (the pool is evacuation's
destination, not a source). Enforce in the pure rules layer, not just the UI.
- **Delete drops members; files are never deleted by a bank op.** Deleting a named
bank removes its member index entries only. No bank operation writes, moves, or
deletes a file — file lifecycle stays with capture/prune. The UI **confirms on
non-empty delete** and offers evacuate; do not silently orphan members.
- **Capture still never inserts into the arrange.** The load-bearing principle is
unchanged; multi-bank only redirects which index the capture lands in.
- **Additive only.** Do not alter `BankIndex`, the M0M11 capture roadmap, or Phase
@@ -484,25 +515,18 @@ verify against the SDK header where they extend existing surfaces:
## Open questions to resolve during build
- **`bank_index` key retirement vs. retention.** Two shapes: (a) the pool's index
rides *inside* the `banks` blob and the legacy `bank_index` key is retired after a
one-way migration; (b) the `bank_index` key is *kept* as the pool's canonical
storage slot and `banks` holds only the named banks + ordering + active id. (a) is
cleaner (one blob, one section) but rewrites where the pool lives; (b) is more
conservative (existing pool persistence untouched, named banks are pure addition)
at the cost of the pool being stored differently from named banks. Leaning (b) for
additive-minimalism against in-flight M7/M8 persist work — but this is a genuine
fork; see product notes.
- **What "delete a named bank" does to its members.** Options: (i) *reabsorb*
member samples move back to the pool (no sample is ever lost to a bank delete);
(ii) *orphan-check* — delete members whose file is referenced by no other bank,
keep the rest; (iii) *forbid non-empty delete* — require the bank be emptied
first. (i) is the safest and simplest mental model (a named bank is a *grouping*,
deleting the group returns things home) and pairs naturally with index-only
movement. Leaning (i); flag for Daniel.
- **Move via drag vs. menu as the primary affordance**, and whether the named-banks
region is REAPER-native tabs (SWELL tab control) or LICE-drawn tabs matching the
grid aesthetic. UI-mechanics detail for the panel build; verify SWELL tab-control
availability against the M5 reference.
- **Active-bank indicator placement** — in the pool/bank region headers, or a
single header readout. Panel-polish detail.
Forks 14 are settled (see product notes → *Settled forks*, and the settled-decision
prose above). Two panel-build items remain open.
- **Fork 5 — tab rendering + move affordance (B4).** Two sub-questions: (5a) the
named-banks region as a SWELL-native tab control vs. LICE-drawn tabs matching the
grid aesthetic; (5b) the move gesture as drag-between-regions vs. a "send to bank"
menu vs. both. Full options analysis (pros/cons across visual consistency, keyboard
nav, HiDPI, cross-platform SWELL parity, scaling, discoverability, mis-drop risk,
MIDI-bindability, implementation cost) and pending recommendations in product notes
*Fork 5*. **Verify SWELL tab-control availability and cross-platform behavior
parity against the SWELL headers / SWS reference** before committing to native tabs.
Decision pending Daniel.
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
settled (fork 4); only the placement is open. Panel-polish detail.