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; 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 + only the add-target is selected upstream. Activating a bank is a model mutation +
a persist write; it never touches the timeline. 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 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 — `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 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 **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" simultaneously (that is the point — copy lets a sample be grouped into "Drums"
while still living in the pool). while still living in the pool).
- **Move vs. copy are distinct acts.** *Move* removes from source, adds to - **Move vs. copy are distinct acts; move is the default.** *Move* removes from
destination (one logical sample, regrouped). *Copy* adds to destination and leaves source, adds to destination (one logical sample, regrouped) — it is the **primary,
the source entry intact (same file, two index entries, two banks). Both are low-friction gesture**, so a sample lives in exactly one bank at a time. *Copy* adds
index-only; both share the destination-collapse rule. Copy is what lets a sample to destination and leaves the source entry intact (same file, two index entries, two
live in the pool *and* a named group at once. banks) — the **deliberate secondary act** for the "in two places at once" case. Both
- **Persistence: a new ext-state key, pool migrates in place.** The multi-bank are index-only; both share the destination-collapse rule. Under move-as-default the
state serializes to a new key `banks` in the existing `"reasampler"` namespace, pool is the default home and staging ground, not a permanent superset: moving a
alongside `bank_index`, `view_state`, and `project_guid`. **Migration:** on load, sample into a named bank takes it out of the pool. (See product notes for the
if a `banks` key is absent but a legacy `bank_index` key is present, the legacy mental-model reconciliation.)
index becomes the pool's `BankIndex` and the book is `{ pool }` with zero named - **Delete drops members; evacuate returns them.** Deleting a **named** bank drops
banks — a one-way, lossless promotion. (Whether `bank_index` is retired or kept as its member index entries (files are **not** deleted — file lifecycle stays owned by
the pool's canonical slot is an open fork — see below.) 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 - **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, 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 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 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 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. 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, 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 and no timeline item is touched. Deleting a **named** bank drops its member index
samples' files (they may be referenced by the pool or another bank via copy); entries but does **not** delete their files; file lifecycle stays owned by the
file lifecycle stays owned by the capture/prune path, not the bank container. See capture/prune path, not the bank container. A file referenced *only* by the deleted
open fork on what "delete a named bank" does to its member samples. 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 - **Travels-with-the-.rpp preserved.** The `banks` blob rides the same ext-state
namespace and the same GUID-primary project-identity / Save-As-relocation 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 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, - `bank_book` — ordered bank registry (`{ bank id, display name, ordinal,
BankIndex }`); pool seeded with fixed id + name; create / rename / reorder / BankIndex }`); pool seeded with fixed id + name; create / rename / reorder /
delete named banks (pool-privilege rules enforced here: reject delete/rename of 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 pool; delete drops member index entries); **evacuate** a bank (move every member to
between banks (index-only, destination-collapse observed); query a bank's index; the pool, index-only, destination-collapse observed; pool cannot be evacuated);
JSON round-trip of the whole book (banks + per-bank indices + active id + active-bank id (get/set, defaults to pool); **move** and **copy** a sample between
ordinals) and legacy-`bank_index`→pool migration on parse. 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: REAPER-facing:
- `persist` (slice) — serialize/deserialize the book under the `banks` key in - `persist` (slice) — serialize/deserialize the book under the `banks` key in
`"reasampler"`; migrate a legacy `bank_index` key into the pool on first load; `"reasampler"` (pool-as-bank-zero inside the blob; no separate `bank_index` key
reload-on-open and Save-As survival ride the existing M4 machinery. The session going forward); migrate a legacy `bank_index` key into the pool on first load
exposes the book the way it exposes the bank today; the active bank's `BankIndex` (one-way; blob authoritative thereafter, legacy key retired); reload-on-open and
is what the capture layer adds to. 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 - `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 tab-page region below; two full-height toggles; the active-bank indicator; the
create / rename / delete / activate affordances; sample move/copy affordance create / rename / delete / activate affordances; sample move/copy affordance
(drag between regions and/or a "send to bank" menu on selection). Reuses the (drag between regions and/or a "send to bank" menu on selection). Reuses the
existing LICE grid render loop per bank region. existing LICE grid render loop per bank region.
- `actions` (entries) — create bank / rename bank / delete bank; activate bank - `actions` (entries) — create bank / rename bank / delete bank (confirm on
(direct + cycle); move selected samples → bank; copy selected samples → bank; non-empty delete); evacuate bank → pool; activate bank (direct + cycle); move
pool/banks full-height toggles. Registered with the `command_id` / `gaccel` / selected samples → bank; copy selected samples → bank; pool/banks full-height
`hookcommand` pattern; bank-activate + move/copy MIDI-bindable to suit the toggles. Registered with the `command_id` / `gaccel` / `hookcommand` pattern;
capture-heavy workflow. bank-activate + move/copy + evacuate MIDI-bindable to suit the capture-heavy
workflow.
## REAPER API surface (verify all signatures) ## 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 - **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. (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, - **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 leave a project with zero banks, or **evacuate** the pool (the pool is evacuation's
UI. 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 - **Capture still never inserts into the arrange.** The load-bearing principle is
unchanged; multi-bank only redirects which index the capture lands in. unchanged; multi-bank only redirects which index the capture lands in.
- **Additive only.** Do not alter `BankIndex`, the M0M11 capture roadmap, or Phase - **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 ## Open questions to resolve during build
- **`bank_index` key retirement vs. retention.** Two shapes: (a) the pool's index Forks 14 are settled (see product notes → *Settled forks*, and the settled-decision
rides *inside* the `banks` blob and the legacy `bank_index` key is retired after a prose above). Two panel-build items remain open.
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 - **Fork 5 — tab rendering + move affordance (B4).** Two sub-questions: (5a) the
cleaner (one blob, one section) but rewrites where the pool lives; (b) is more named-banks region as a SWELL-native tab control vs. LICE-drawn tabs matching the
conservative (existing pool persistence untouched, named banks are pure addition) grid aesthetic; (5b) the move gesture as drag-between-regions vs. a "send to bank"
at the cost of the pool being stored differently from named banks. Leaning (b) for menu vs. both. Full options analysis (pros/cons across visual consistency, keyboard
additive-minimalism against in-flight M7/M8 persist work — but this is a genuine nav, HiDPI, cross-platform SWELL parity, scaling, discoverability, mis-drop risk,
fork; see product notes. MIDI-bindability, implementation cost) and pending recommendations in product notes
- **What "delete a named bank" does to its members.** Options: (i) *reabsorb* *Fork 5*. **Verify SWELL tab-control availability and cross-platform behavior
member samples move back to the pool (no sample is ever lost to a bank delete); parity against the SWELL headers / SWS reference** before committing to native tabs.
(ii) *orphan-check* — delete members whose file is referenced by no other bank, Decision pending Daniel.
keep the rest; (iii) *forbid non-empty delete* — require the bank be emptied - **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
first. (i) is the safest and simplest mental model (a named bank is a *grouping*, header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
deleting the group returns things home) and pairs naturally with index-only settled (fork 4); only the placement is open. Panel-polish detail.
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.
+67 -46
View File
@@ -98,42 +98,53 @@ privileged, create/rename/reorder/delete named banks, active-bank id, move/copy
sample between banks, JSON round-trip + legacy-migration. The heart of the phase; sample between banks, JSON round-trip + legacy-migration. The heart of the phase;
mirror of `bank_model` / `view_mode_model`; **`BankIndex` untouched (additive)**. mirror of `bank_model` / `view_mode_model`; **`BankIndex` untouched (additive)**.
CONTEXT.md §Multi-bank (Module architecture — pure). CONTEXT.md §Multi-bank (Module architecture — pure).
**Verify:** CTest green. Pool always present, un-deletable, un-renamable (rules **Verify:** CTest green. Pool always present, un-deletable, un-renamable,
rejected in-model). Active-bank defaults to pool. Move is index-only (source loses un-evacuable (rules rejected in-model). Active-bank defaults to pool. Move is
entry, destination gains it) and observes destination collapse-by-hash; copy leaves index-only (source loses entry, destination gains it) and observes destination
source intact. JSON round-trip lossless across banks + per-bank indices + ordinals + collapse-by-hash; copy leaves source intact. Delete drops member index entries.
Evacuate moves all members to the pool, leaving the bank empty. JSON round-trip
lossless across pool-as-bank-zero + named banks + per-bank indices + ordinals +
active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks. active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks.
- [ ] Bank registry: ordered `{ bank id, display name, ordinal, BankIndex }`; pool - [ ] Bank registry: ordered `{ bank id, display name, ordinal, BankIndex }`; pool
seeded with fixed id + fixed name; create / rename / reorder / delete named banks. seeded with fixed id + fixed name; create / rename / reorder / delete named banks
(delete drops the bank's member index entries).
- [ ] Pool-privilege rules enforced in-model: reject delete-pool, reject - [ ] Pool-privilege rules enforced in-model: reject delete-pool, reject
rename-pool, never allow zero banks. rename-pool, reject evacuate-pool, never allow zero banks.
- [ ] Active-bank id (get/set; defaults to pool); resolve active bank's `BankIndex`. - [ ] Active-bank id (get/set; defaults to pool); resolve active bank's `BankIndex`.
- [ ] Move sample between banks (index-only; destination collapse-by-hash observed; - [ ] Move sample between banks (index-only; destination collapse-by-hash observed;
source entry removed). source entry removed).
- [ ] Copy sample between banks (index-only; source entry retained; destination - [ ] Copy sample between banks (index-only; source entry retained; destination
collapse-by-hash observed). collapse-by-hash observed).
- [ ] JSON round-trip: banks + per-bank indices + ordinals + active id. - [ ] Evacuate bank: move every member to the pool (index-only; destination
collapse-by-hash observed), leaving the bank empty; pool cannot be evacuated.
- [ ] JSON round-trip: pool-as-bank-zero inside the blob + named banks + per-bank
indices + ordinals + active id.
- [ ] Legacy migration: a bare `bank_index` JSON promotes to the pool's index with - [ ] Legacy migration: a bare `bank_index` JSON promotes to the pool's index with
zero named banks (one-way, lossless). zero named banks (one-way, lossless; blob authoritative thereafter).
- [ ] Tests: pool privileges (delete/rename rejected); move source-loses/dest-gains; - [ ] Tests: pool privileges (delete/rename/evacuate rejected); move
copy source-retained; cross-bank same-hash coexistence; dest collapse on move into source-loses/dest-gains; copy source-retained; evacuate empties source into pool
a bank already holding the hash; JSON lossless; legacy migration. with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a
bank already holding the hash; JSON lossless; legacy migration.
## B2 — persist slice (banks ↔ project ext state) ## B2 — persist slice (banks ↔ project ext state)
**Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the **Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the
existing sections; migrate a legacy `bank_index` key into the pool on first load; existing sections, with the pool folded in as bank-zero; migrate a legacy
reload-on-open and Save-As survival via the existing M4 machinery. CONTEXT.md `bank_index` key into the pool on first load and retire the legacy key; reload-on-open
§Multi-bank (persist). **Verify (in DAW):** Banks + named banks + active bank + all and Save-As survival via the existing M4 machinery. CONTEXT.md §Multi-bank (persist).
per-bank samples survive Save / Save As / close+reopen; **relative paths only**; **Verify (in DAW):** Banks + named banks + active bank + all per-bank samples survive
bank travels with the `.rpp`; a project saved before this phase (legacy `bank_index` Save / Save As / close+reopen; **relative paths only**; bank travels with the `.rpp`;
only) loads as pool + zero named banks with no sample loss. a project saved before this phase (legacy `bank_index` only) loads as pool + zero
**Depends on:** B1. Resolve the `bank_index` retirement-vs-retention fork first named banks with no sample loss, and after save carries `banks` with no `bank_index`
(see Phase B open questions). written.
**Depends on:** B1. (Persistence-key fork settled — fork 1 (a): pool inside the
`banks` blob, legacy key retired after one-way migration.)
- [ ] Serialize/deserialize the book under the `banks` key (shared blob, distinct - [ ] Serialize/deserialize the book under the `banks` key (pool-as-bank-zero inside
section from `bank_index` / `view_state`). the blob; distinct section from `view_state`; no `bank_index` key written going
- [ ] Legacy-migration path on load: absent `banks` + present `bank_index` → pool. forward).
- [ ] Legacy-migration path on load: absent `banks` + present `bank_index` → promote
into pool, mint the blob, treat blob as authoritative (legacy key retired).
- [ ] Session exposes the book; the active bank's `BankIndex` is the capture add - [ ] Session exposes the book; the active bank's `BankIndex` is the capture add
target (route the M7 capture family through it — additive to M7, no M7 rewrite). target (route the M7 capture family through it — additive to M7, no M7 rewrite).
- [ ] Confirm survival across Save / Save As; confirm legacy-project load path. - [ ] Confirm survival across Save / Save As; confirm legacy-project load path.
@@ -141,44 +152,54 @@ only) loads as pool + zero named banks with no sample loss.
## B3 — actions ## B3 — actions
**Goal:** Bindable action set for the multi-bank workflow. CONTEXT.md §Multi-bank **Goal:** Bindable action set for the multi-bank workflow. CONTEXT.md §Multi-bank
(actions). **Verify (in DAW):** Each action registered (bindable in Actions list); (actions). **Verify (in DAW):** Each action registered (bindable in Actions list);
bank-activate + move/copy MIDI-bindable; create/rename/delete drive the B1 model via bank-activate + move/copy + evacuate MIDI-bindable; create/rename/delete/evacuate
the B2-persisted session. drive the B1 model via the B2-persisted session.
**Depends on:** B1, B2. **Depends on:** B1, B2.
- [ ] Create bank / rename bank / delete bank (delete honors the resolved - [ ] Create bank / rename bank / delete bank (delete drops member index entries;
member-disposition rule — see open questions). confirm-on-non-empty offered at the UI layer in B4).
- [ ] Evacuate bank → pool (move all members back to the pool; refuses on the pool).
- [ ] Activate bank (direct-by-id + cycle). - [ ] Activate bank (direct-by-id + cycle).
- [ ] Move selected samples → bank / copy selected samples → bank. - [ ] Move selected samples → bank / copy selected samples → bank (move is default).
- [ ] Pool full-height / banks full-height toggles. - [ ] Pool full-height / banks full-height toggles.
- [ ] Register each (`command_id`/`gaccel`/`hookcommand`); bank-activate + move/copy - [ ] Register each (`command_id`/`gaccel`/`hookcommand`); bank-activate + move/copy
MIDI-bindable. + evacuate MIDI-bindable.
## B4 — bank_panel vertical split (UI) ## B4 — bank_panel vertical split (UI)
**Goal:** The vertical-split bank window — pool on top, named-banks tab-page region **Goal:** The vertical-split bank window — pool on top, named-banks tab-page region
below, full-height toggles — extending the M5 docked grid. CONTEXT.md §Multi-bank below, full-height toggles — extending the M5 docked grid. CONTEXT.md §Multi-bank
(bank_panel). **Verify (in DAW):** Pool grid renders on top; named-banks tab strip (bank_panel). **Verify (in DAW):** Pool grid renders on top; named-banks tab strip
below (empty when no named banks, one tab per named bank); active-bank indicated; below (empty when no named banks, one tab per named bank); active-bank **unmistakably**
both full-height toggles collapse the split correctly; sample move/copy affordance indicated; both full-height toggles collapse the split correctly; sample move/copy
works (drag and/or menu); the Design View mode switch in the header is unaffected. affordance works; non-empty delete confirms and offers evacuate; the Design View mode
**Depends on:** B1, B2, B3. switch in the header is unaffected.
**Depends on:** B1, B2, B3. (Tab rendering + move-affordance mechanics — fork 5 —
pending Daniel's decision; see Phase B open questions and product notes → *Fork 5*.)
- [ ] Vertical split: pool grid region (top) + named-banks tab-page region (bottom). - [ ] Vertical split: pool grid region (top) + named-banks tab-page region (bottom).
- [ ] Named-banks tab strip: one tab per named bank; empty state when none. - [ ] Named-banks tab strip: one tab per named bank; empty state when none.
(SWELL-native vs. LICE-drawn — fork 5a, pending.)
- [ ] Pool full-height / banks full-height toggle affordances wired to B3. - [ ] Pool full-height / banks full-height toggle affordances wired to B3.
- [ ] Active-bank indicator. - [ ] Active-bank indicator**visually unmistakable** (settled constraint);
- [ ] Create / rename / delete / activate affordances driving B3 actions. placement (per-region header / single readout / lit-tab) is the residual polish
- [ ] Sample move/copy affordance (drag between regions and/or "send to bank" menu). detail.
- [ ] Create / rename / delete / activate / evacuate affordances driving B3 actions.
- [ ] Delete confirms on a non-empty bank, naming the evacuate alternative.
- [ ] Sample move/copy affordance (drag between regions and/or "send to bank" menu —
fork 5b, pending).
## Phase B open questions ## Phase B open questions
- **`bank_index` key retirement vs. retention** — (a) fold the pool into the `banks` Forks 14 settled by Daniel (2026-07-23): persistence key = fold pool into `banks`,
blob and retire the legacy key, vs (b) keep `bank_index` as the pool's canonical retire legacy key (1a); delete drops members + add evacuate verb (2); move is the
slot and store only named banks under `banks`. Leaning (b) for additive-minimalism default gesture (3); active-bank/shown-tab distinct with an unmistakable indicator
against in-flight M7/M8 persist work. Resolve before B2. (blocks B2) (4). Folded into CONTEXT.md §Multi-bank + the B1B4 points above. Remaining:
- **Named-bank delete → member disposition** — (i) reabsorb into pool, (ii)
orphan-check by file reference, (iii) forbid non-empty delete. Leaning (i). - **Fork 5 — tab rendering + move affordance** — (5a) SWELL-native tab control vs.
(touches B1/B3) LICE-drawn tabs matching the grid aesthetic; (5b) drag-between-regions vs.
- **Move affordance + tab rendering** — drag vs. menu as primary; SWELL-native vs. "send to bank" menu vs. both. Options analysis + pending recommendations in product
LICE-drawn tabs. Verify SWELL tab-control availability against the M5 reference. notes → *Fork 5*. Verify SWELL tab-control availability + cross-platform parity
against the SWELL headers / SWS reference before choosing native tabs. Decision
pending Daniel. (touches B4)
- **Active-bank indicator placement** — per-region headers vs. single header readout
vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail.
(touches B4) (touches B4)
- **Active-bank indicator placement** — per-region headers vs. single header
readout. (touches B4)
+287 -90
View File
@@ -6,8 +6,9 @@ detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
narrative, the pool-privilege reasoning, the movement semantics, and the narrative, the pool-privilege reasoning, the movement semantics, and the
design-direction recommendations — so those don't clutter the build docs. design-direction recommendations — so those don't clutter the build docs.
Status: framed by product-designer (2026-07-23), pending Daniel review. Open items Status: framed by product-designer (2026-07-23). Forks 14 settled by Daniel
for Daniel are listed at the bottom. (2026-07-23); fork 5 (tab rendering + move affordance) still open — options
analysis at the bottom. Settled forks are folded into the prose below.
--- ---
@@ -18,9 +19,11 @@ banks.** Today ReaSampler has exactly one bank (`BankIndex`) — every capture l
in it, and it is the whole library. That is fine at ten samples and unusable at two in it, and it is the whole library. That is fine at ten samples and unusable at two
hundred. Multi-bank keeps the one bank as **the pool** — the default catch-all hundred. Multi-bank keeps the one bank as **the pool** — the default catch-all
every capture still lands in — and lets the user create **named banks** ("Drums", every capture still lands in — and lets the user create **named banks** ("Drums",
"1-Shots", "Synth Hits") that group samples for a purpose. Samples move (or copy) "1-Shots", "Synth Hits") that group samples for a purpose. **Move** is the primary
freely between any banks, including to and from the pool. One bank at a time is the gesture — a sample relocates from one bank to another, living in exactly one bank at
**active bank**: the capture target. The pool is active by default. a time — with **copy** the deliberate secondary act when the user wants a sample in
two banks at once. One bank at a time is the **active bank**: the capture target. The
pool is active by default.
**It is not a new file layout.** Banks are *logical groupings over one shared file **It is not a new file layout.** Banks are *logical groupings over one shared file
pool*, not folders on disk. Moving a sample between banks moves an index entry, not pool*, not folders on disk. Moving a sample between banks moves an index entry, not
@@ -94,14 +97,17 @@ tested core, not a modification of it.
## Movement semantics (the settled rules, in prose) ## Movement semantics (the settled rules, in prose)
- **Move is index-only.** Moving a sample from bank A to bank B removes the `Sample` - **Move is index-only, and is the default gesture.** Moving a sample from bank A to
from A's `BankIndex` and adds it to B's. The file never moves — one shared file bank B removes the `Sample` from A's `BankIndex` and adds it to B's. The file never
pool under the project bank folder, exactly where M4 put it. Cheap, moves — one shared file pool under the project bank folder, exactly where M4 put it.
non-destructive, immune to the path-rewrite bug class M4 closed. Cheap, non-destructive, immune to the path-rewrite bug class M4 closed. After a move
- **Copy is index-only too.** Copy adds the sample to B and *leaves it in A*. Two the sample lives in exactly one bank; this is the primary, low-friction affordance
index entries, one file, two banks. Copy is the mechanism that lets a sample live (see design-direction, below).
in the pool *and* in a named group at once — the pool stays the complete library, - **Copy is index-only too, and is the deliberate secondary act.** Copy adds the
and "Drums" is a curated view into part of it. sample to B and *leaves it in A*. Two index entries, one file, two banks. Copy is
the mechanism that lets a sample live in the pool *and* in a named group at once —
or in two named groups. It is the explicit way to keep a sample in more than one
place; move (the default) puts it in one place.
- **Dedup-by-hash is per-bank, and collapse is observed across a move.** Each - **Dedup-by-hash is per-bank, and collapse is observed across a move.** Each
`BankIndex` dedups within itself, unchanged. If you move (or copy) a sample into a `BankIndex` dedups within itself, unchanged. If you move (or copy) a sample into a
bank that already holds its hash, the destination *collapses* onto its existing bank that already holds its hash, the destination *collapses* onto its existing
@@ -127,10 +133,18 @@ trivially correct.
## Persistence and migration ## Persistence and migration
The book rides the existing `"reasampler"` project ext-state namespace under a new The book rides the existing `"reasampler"` project ext-state namespace under a new
key, `banks`, alongside `bank_index`, `view_state`, and `project_guid`. Same key, `banks`, alongside `view_state` and `project_guid`. Same namespace, same
namespace, same travel-with-the-`.rpp` guarantee, same GUID-primary identity and travel-with-the-`.rpp` guarantee, same GUID-primary identity and Save-As-relocation
Save-As-relocation machinery from M4. One shared physical bank folder; one machinery from M4. One shared physical bank folder; one ext-state namespace.
ext-state namespace; now three logical sections.
**The pool folds into the `banks` blob; the legacy `bank_index` key is retired
(fork 1, settled (a)).** The pool's index is stored *inside* the `banks` blob as
bank-zero, exactly like every named bank — one blob, one section, one JSON shape.
The old top-level `bank_index` key is not written going forward; after migration the
`banks` blob is the single authoritative source for all bank state including the
pool. This is the cleaner shape and keeps the "pool is just bank-zero" data-model
uniformity honest all the way down to storage: the pool is not persisted
differently from a named bank.
**Migration of an existing single-bank project is the load-bearing edge case.** A **Migration of an existing single-bank project is the load-bearing edge case.** A
project saved before this phase has a `bank_index` key and no `banks` key. On load, project saved before this phase has a `bank_index` key and no `banks` key. On load,
@@ -140,12 +154,17 @@ the user opens their old project and finds everything in the pool exactly as bef
now with the ability to add named banks. This must be a first-class, tested path now with the ability to add named banks. This must be a first-class, tested path
(B1 pure-model migration + B2 in-DAW load), not an afterthought. (B1 pure-model migration + B2 in-DAW load), not an afterthought.
There is one genuine fork here, called out in the open items: whether the pool's **The migration is a one-time promotion, and it is one-way.** On load: legacy
index lives *inside* the `banks` blob (retire the legacy `bank_index` key) or the `bank_index` present, `banks` absent → promote the legacy index into the pool inside
`bank_index` key is *kept* as the pool's canonical slot with `banks` holding only a freshly-minted `banks` blob, then treat the blob as authoritative. Once the `banks`
named banks. Both migrate cleanly; the trade is cleanliness (one blob) vs. blob exists it is the only source consulted; the legacy key is not read back or
additive-minimalism (existing pool persistence untouched, named banks pure kept in sync. A project touched by this version and saved will carry `banks` and
addition). The lean recommendation is the conservative one — see open items. (going forward) will not carry a written `bank_index` — the retirement is complete
after the first save. The promotion is lossless: no sample, no path, no ordinal is
dropped. This trades a small amount of forward-only commitment (a project saved by
this version will not re-open cleanly in a pre-multi-bank build that only knows
`bank_index`) for the clean single-blob shape — an acceptable, one-directional
migration, consistent with how M4 project state already moves forward.
--- ---
@@ -159,31 +178,87 @@ are properties of the capture path and the file. The invariants it *does* touch:
`BankIndex` in the book, by the exact code that enforces it today. Movement adds `BankIndex` in the book, by the exact code that enforces it today. Movement adds
no path handling because files never relocate. no path handling because files never relocate.
- **Non-destructive** extends to bank operations: create / rename / delete / - **Non-destructive** extends to bank operations: create / rename / delete /
activate and sample move / copy mutate only index + ext-state. No file is written, activate / evacuate and sample move / copy mutate only index + ext-state. No file
moved, or deleted; no timeline item is touched. In particular, **deleting a named is written, moved, or deleted; no timeline item is touched. In particular,
bank does not delete its samples' files** — a file may be referenced by the pool **deleting a named bank drops its member index entries but does not delete their
or another bank via copy, and file lifecycle stays owned by the capture/prune files** — file lifecycle stays owned by the capture/prune path, never the bank
path, never the bank container. container (fork 2, settled). A file that was 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 a designed
consequence of "delete drops members, files persist," not an accident (see
*design-direction → evacuate* below for how the workflow avoids it).
- **Travels-with-the-.rpp** is preserved by riding the M4 machinery unchanged. - **Travels-with-the-.rpp** is preserved by riding the M4 machinery unchanged.
--- ---
## Design-direction recommendations (opinionated) ## Design-direction (settled forks 24, plus rationale)
### The pool stays the complete library; named banks are curated views ### Move is the default; the pool is a bank, not a permanent mirror
The strongest mental model — and the one the index-only, copy-friendly design is **Settled (fork 3): move is the primary, low-friction gesture; copy is the
built for — is: **the pool is everything you've captured; named banks are curated deliberate secondary act.** This inverts the earlier framing (which leaned
subsets you assemble by hand.** Copy (not move) into a named bank keeps the pool copy-as-default on a "pool is the complete library, banks are views" model). Daniel's
complete, so the user can always fall back to "it's in the pool somewhere" while call is a *partition* mental model: a sample lives in one bank at a time, and moving
"Drums" stays a clean working set. Move is there for the user who wants a strict it to "Drums" takes it *out* of the pool. Copy remains available for the user who
partition, but copy is the gentler default the UI should make easy. This is genuinely wants a sample in two banks at once, but it is the explicit, deliberate act
borrowed from playlist-vs-library models (music apps, Lightroom collections): the — not the default.
library is authoritative and complete; collections are lightweight views over it.
Recommendation: make **copy the low-friction gesture** (drag, or a one-click "add to This changes what "the pool is the complete library" means, and the docs must be
bank") and **move the deliberate one** (explicit "move" menu item). Do not force the honest about it. Under move-as-default the pool is **not** a permanent mirror of
user to choose partition semantics up front. everything ever captured — it is the default catch-all every capture lands in *and*
the from-which/back-to-which home for organizing, but once a user moves a sample into
a named bank it is no longer in the pool. The honest framing is:
- **The pool is the default home and the staging ground**, not a guaranteed superset.
Fresh captures land there; from there the user *moves* them into named banks. What
is "in the pool" is what hasn't been filed yet plus whatever the user deliberately
keeps there.
- **Named banks are where filed samples live**, not lightweight views over a
still-complete library. A sample in "Drums" is *in Drums* and nowhere else unless
copied.
- **Copy is the escape hatch** for the "I want this in two places" case — borrowed
from the move-vs-copy distinction every file manager makes. It is deliberately the
less prominent of the two so the default stays a clean partition.
Prior art: this is the file-manager / DAW-media-pool model (move relocates, copy
duplicates) rather than the playlist-library model. It suits a capture-heavy workflow
where the user is actively filing rather than curating views over a fixed corpus.
### Evacuate: the "keep the samples, drop the grouping" gesture
**Settled (fork 2): delete drops a bank's member index entries; a separate
*evacuate* operation moves all members back to the pool.** These are two distinct
verbs for two distinct intents:
- **Delete** removes the bank *and its member index entries*. Files are not touched
(capture/prune owns file lifecycle). Use it when you want the grouping *and its
contents' index entries* gone.
- **Evacuate** moves every member of the bank back to the pool (index-only, observing
the same destination-collapse-by-hash as a normal move), leaving the bank empty.
Use it when you want to dissolve the grouping *but keep the samples*.
The intended workflow is **"evacuate, then delete"** when the user wants to keep the
samples and drop only the grouping, versus a **plain delete** when they want the
grouping and its members gone. Evacuate is a pure `bank_book` operation (B1) and a
bindable action (B3). Pool-privilege rules still apply: you cannot evacuate the pool
(it has nowhere to evacuate *to*, and it is the evacuation destination).
**The non-empty-delete hazard, and the recommended guardrail.** Because delete drops
members (rather than reabsorbing them, the earlier lean) and move is the default (so a
sample is often in exactly one bank), a plain delete of a *non-empty* named bank can
orphan those samples out of every index — their files persist on disk until prune,
but no bank references them. That is the orphaned-until-prune window called out in the
precision-invariant section. It is a real footgun: "I deleted Drums and my kick is
gone from the library."
Recommendation (B4, panel-polish, pending Daniel): **confirm on non-empty delete**,
with the confirmation naming the escape hatch. Something like *"Delete 'Drums'? Its 14
samples will be removed from the library (files remain on disk until pruned).
Evacuate to pool instead?"* — a three-way affordance (Delete / Evacuate then delete /
Cancel) turns the hazard into a teaching moment for the evacuate verb. An *empty*
bank deletes with no confirmation (nothing to lose). This keeps delete fast where it's
safe and guarded where it bites. I recommend adopting the confirm-on-non-empty
guardrail; the exact copy and button set are a B4 detail.
### The vertical split: pool on top, named banks as a tab strip below ### The vertical split: pool on top, named banks as a tab strip below
@@ -203,20 +278,25 @@ curated views are the swappable bottom region.
(hide the pool — "I'm organizing"). These are mode-of-work toggles, not layout (hide the pool — "I'm organizing"). These are mode-of-work toggles, not layout
fiddling, and they map to the two halves of the workflow (capture vs. curate). fiddling, and they map to the two halves of the workflow (capture vs. curate).
### Keep "active bank" distinct from "shown tab" ### Active bank and shown tab stay distinct (settled fork 4)
A subtle but important call: the **capture-active** bank (where new captures land) **Settled (fork 4): the capture-active bank and the currently-shown named-bank tab
and the **currently-shown** named-bank tab are *different* concepts. You might be are distinct concepts, and the active bank carries an unmistakable visual
looking at "Drums" while capturing into the pool, or vice versa. Conflating them indicator.** The **capture-active** bank (where new captures land) and the
("the tab you're viewing is the capture target") would make it too easy to **currently-shown** tab are *different* things — you might be looking at "Drums" while
capture into the wrong bank by merely browsing. capturing into the pool, or vice versa. Browsing a tab **never** retargets capture.
Conflating them ("the tab you're viewing is the capture target") was considered and
rejected: it makes it too easy to capture into the wrong bank by merely browsing, and
capture-into-wrong-bank is a silent error the user won't notice until later.
Recommendation: **active bank is an explicit state with a clear indicator**, set by Active bank is an explicit state, set by an explicit "activate" affordance (and
an explicit "activate" affordance (and action), *not* implied by which tab is shown. action), *not* implied by which tab is shown. The pool is active by default;
The pool is active by default; activating a named bank is a deliberate act. The activating a named bank is a deliberate act. **The indicator must be visually
indicator should be unmistakable — the capture target is a thing you want to be sure unmistakable** — this is now a settled constraint, not a suggestion. The capture
of before you hit capture. Placement (per-region header vs. single readout) is a target is a thing the user must be able to verify at a glance before hitting capture.
panel-polish open item. The indicator's exact placement (per-region header vs. single header readout vs.
lit-tab treatment) remains a B4 panel-polish detail — but that it reads unmistakably
is fixed.
### The Design View mode switch and multi-bank are orthogonal — and should read that way ### The Design View mode switch and multi-bank are orthogonal — and should read that way
@@ -247,11 +327,14 @@ Stable command-id strings follow the sampler family prefix. Bank-activate and
move/copy are MIDI-bindable to suit the capture-heavy, hands-on workflow. move/copy are MIDI-bindable to suit the capture-heavy, hands-on workflow.
- **Create bank** / **Rename bank** / **Delete bank** — manage the named-bank set. - **Create bank** / **Rename bank** / **Delete bank** — manage the named-bank set.
(Pool is un-deletable / un-renamable — the actions refuse on the pool.) (Pool is un-deletable / un-renamable — the actions refuse on the pool.) Delete on a
non-empty bank confirms (see evacuate, above).
- **Evacuate bank → pool** — move all of a bank's members back to the pool, leaving
it empty; the "keep the samples, drop the grouping" verb. (Refuses on the pool.)
- **Activate bank** (direct-by-id) / **Cycle active bank** — set the capture target; - **Activate bank** (direct-by-id) / **Cycle active bank** — set the capture target;
distinct from browsing a tab. distinct from browsing a tab.
- **Move selected samples → bank** / **Copy selected samples → bank** — the two - **Move selected samples → bank** / **Copy selected samples → bank** — the two
movement verbs; copy the low-friction default per the recommendation above. movement verbs; move is the low-friction default, copy the deliberate secondary.
- **Pool full-height** / **Banks full-height** (toggles) — collapse the split. - **Pool full-height** / **Banks full-height** (toggles) — collapse the split.
--- ---
@@ -269,20 +352,25 @@ Mirrors the capture and Design View pillars exactly.
- Active-bank id (get/set, defaults to pool); resolve the active bank's `BankIndex`. - Active-bank id (get/set, defaults to pool); resolve the active bank's `BankIndex`.
- Move / copy a sample between banks — index-only, destination collapse-by-hash - Move / copy a sample between banks — index-only, destination collapse-by-hash
observed, move removes the source entry. observed, move removes the source entry.
- Evacuate a bank — move every member to the pool (index-only, same destination
collapse-by-hash as move), leaving the bank empty; pool cannot be evacuated.
- JSON round-trip of the whole book (banks + per-bank indices + ordinals + active - JSON round-trip of the whole book (banks + per-bank indices + ordinals + active
id); legacy-`bank_index`→pool migration on parse. id), pool included as bank-zero *inside* the blob; legacy-`bank_index`→pool
migration on parse (one-way, blob becomes authoritative).
- `BankIndex` is untouched — `bank_book` wraps, never modifies it. - `BankIndex` is untouched — `bank_book` wraps, never modifies it.
**`persist` slice:** **`persist` slice:**
- Serialize/deserialize the book under the `banks` key in `"reasampler"` (shared - Serialize/deserialize the book under the `banks` key in `"reasampler"` (shared
blob, distinct section from `bank_index` / `view_state`). namespace, distinct section from `view_state`; the pool rides *inside* the `banks`
- Migrate a legacy `bank_index` key into the pool on first load. blob as bank-zero — no separate `bank_index` key going forward).
- Migrate a legacy `bank_index` key into the pool on first load (one-way, lossless;
after migration the `banks` blob is authoritative and the legacy key is retired).
- Reload-on-open and Save-As survival via the existing M4 machinery; the session - Reload-on-open and Save-As survival via the existing M4 machinery; the session
exposes the book; the active bank's `BankIndex` is the capture add target. exposes the book; the active bank's `BankIndex` is the capture add target.
**`actions` entries:** the set listed above, registered with the **`actions` entries:** the set listed above (including **evacuate bank → pool**),
`command_id` / `gaccel` / `hookcommand` pattern; bank-activate + move/copy registered with the `command_id` / `gaccel` / `hookcommand` pattern; bank-activate +
MIDI-bindable. move/copy + evacuate MIDI-bindable.
**UI (extending the M5 `bank_panel`):** the vertical split (pool grid top, **UI (extending the M5 `bank_panel`):** the vertical split (pool grid top,
named-banks tab-page region bottom); the two full-height toggles; the active-bank named-banks tab-page region bottom); the two full-height toggles; the active-bank
@@ -292,33 +380,142 @@ the M5 LICE grid render loop per region.
--- ---
## Open items for Daniel ## Settled forks (Daniel, 2026-07-23)
1. **`bank_index` key retirement vs. retention.** Two persistence shapes: (a) fold Four of the five open forks are now decided; their implications are folded into the
the pool's index *into* the `banks` blob and retire the legacy `bank_index` key prose above. Recorded here as a scan-line so the decisions don't have to be
after a one-way migration — cleaner, one blob, one section; (b) *keep* the reconstructed from the body text.
`bank_index` key as the pool's canonical storage slot and store only named banks
+ ordering + active id under `banks` — more conservative, existing pool 1. **Persistence key — (a), retire `bank_index`.** The pool folds into the `banks`
persistence untouched, named banks a pure addition. I lean **(b)** because M7/M8 blob as bank-zero; the legacy `bank_index` key is retired after a one-way,
persist work is in flight in another worktree and (b) leaves the existing lossless migration; the blob is authoritative thereafter. (See *persistence and
`bank_index` write path alone. But it's a genuine fork with a real trade — your migration*.)
call before B2 is scoped. 2. **Delete drops members; add an *evacuate* verb.** Deleting a named bank drops its
2. **Named-bank delete → member disposition.** When a user deletes a *named* bank member index entries (files untouched, owned by capture/prune). A separate
holding samples: (i) *reabsorb* members move back to the pool, nothing is ever *evacuate* operation moves all members back to the pool. Workflow: "evacuate then
lost to a delete; (ii) *orphan-check* — delete members no other bank references, delete" to keep samples; plain delete to drop the grouping and its members.
keep the rest; (iii) *forbid non-empty delete* — require emptying first. I lean Non-empty delete should confirm and offer evacuate. (See *design-direction →
**(i)** — a named bank is a grouping, deleting the group returns things home, and evacuate*; orphaned-until-prune consequence in *precision-invariant implications*.)
it pairs naturally with index-only movement. Confirm or redirect. 3. **Move is the default gesture; copy is the deliberate secondary.** A sample lives
3. **Move vs. copy defaults.** I'm recommending **copy as the low-friction gesture** in one bank at a time; copy is the explicit "in two places" escape hatch. (See
(drag / one-click) and **move as the deliberate one** (explicit menu), on the *design-direction → move is the default*.)
"pool is the complete library" model. If you'd rather move be the default (strict 4. **Active bank and shown tab stay distinct; active bank gets an unmistakable
partition mental model), say so — it changes the primary affordance. indicator.** Browsing never retargets capture; the "visually unmistakable"
4. **Active-bank vs. shown-tab separation.** I'm recommending these stay *distinct* requirement is a settled constraint (placement remains a B4 detail). (See
(browsing a tab does not change the capture target; activation is explicit). If *design-direction → active bank and shown tab stay distinct*.)
you'd prefer the simpler "the tab you're viewing is the capture target" coupling,
flag it — it's a real simplification but risks capturing into the wrong bank by ---
browsing.
5. **Tab rendering + move affordance mechanics.** SWELL-native tab control vs. ## Fork 5 — options for discussion, decision pending
LICE-drawn tabs matching the grid aesthetic; drag-between-regions vs. menu as the
primary move gesture. Panel-build detail — noted for B4, not phase-defining, and The one fork Daniel wants to weigh before deciding. Two sub-questions, both B4
the SWELL tab-control availability needs verification against the M5 reference. panel-build mechanics, neither phase-defining but both shaping the panel's feel.
Nothing below is decided; the recommendations at the end are *pending Daniel's call*.
### 5a — Named-banks tab rendering: SWELL-native tabs vs. LICE-drawn custom tabs
The named-banks region needs a tab strip (one tab per named bank). Two ways to draw
it.
**Option A — SWELL-native tab control** (`SWELL`'s tab-control, the cross-platform
Win32 `SysTabControl32` analog). *Verify against the SWELL headers* — SWELL tab-control
availability and behavior-parity across Win/mac/Linux is a known soft spot and must be
checked, not assumed; the SWS extension is the reference for which SWELL controls are
safe to lean on.
- **Pros:** near-zero custom draw/hit-test code; keyboard navigation (arrow between
tabs, focus handling) comes for free on platforms where SWELL implements it;
gets native-ish affordances (overflow, tooltips) without hand-rolling.
- **Cons:** *visual inconsistency* — a native tab control will not match the M5 LICE
grid aesthetic or the Design View segmented switch; it will read as a bolted-on OS
widget in an otherwise custom-drawn surface. HiDPI/scaling behavior of SWELL controls
is uneven across platforms. Cross-platform parity is the real risk: the control may
look and behave three different ways on Win/mac/Linux, and SWELL's tab
implementation is less battle-tested than its buttons/edits.
- **Scaling:** native tab controls handle many tabs via built-in overflow/scroll, so
it degrades more gracefully past ~1015 banks than a naive custom strip would.
**Option B — LICE-drawn custom tabs** matching the grid aesthetic (the same render
path M5 already uses, and the same approach the Design View segmented mode switch
`[ Arrange | Design ]` took).
- **Pros:** *visual consistency* — tabs match the grid and the existing segmented
switch; one coherent custom-drawn surface. Full control over HiDPI (draw at the
panel's own scale factor, as M5 already must). Identical look and behavior on all
three platforms because we own every pixel — no SWELL-parity risk. Precedent
exists in-repo: the Design View segmented switch is already a LICE-drawn
multi-segment selector, so the pattern and hit-testing are partly solved.
- **Cons:** we hand-roll hit-testing, keyboard navigation, and overflow. Keyboard nav
(tab/arrow to move between banks) is real work we'd otherwise get free from a native
control. Overflow past the visible width needs a scroll or chevron affordance we
build ourselves.
- **Scaling:** a naive fixed-width custom strip breaks down when named banks exceed the
region width (~812 tabs depending on name length) unless we build scroll/overflow.
This is the sharpest cost of B — but the Design View switch only ever has two
segments, so its precedent doesn't cover the many-tabs case; we'd extend it.
*Framing note:* the choice is largely "buy keyboard-nav + overflow from SWELL and pay
in visual inconsistency + cross-platform risk" (A) vs. "pay to build keyboard-nav +
overflow and get visual consistency + platform certainty" (B). How many named banks
we expect in practice is the swing factor: if a user rarely exceeds ~68 banks, B's
overflow cost is small and its consistency win is large; if banks routinely run into
the dozens, A's built-in overflow starts to matter.
### 5b — Move affordance: drag-between-regions vs. "send to bank" menu vs. both
The move gesture (now the default, per fork 3) needs a surface. Three shapes.
**Option A — drag between regions** (drag selected samples from the pool grid onto a
named-bank tab, or between tabs).
- **Pros:** spatially intuitive; matches the mental model ("put this there"); direct.
- **Cons:** *precision / mis-drop risk* is real on a dense grid — dragging onto the
wrong tab or dropping in dead space is easy, and a mis-drop that silently moves a
sample to the wrong bank is exactly the "silent wrong-bank" error we're guarding
against elsewhere. Not keyboard- or MIDI-bindable — but B3 already specifies the
move action *is* MIDI-bindable, so drag can't be the *only* surface. Drag also has
to interoperate cleanly with the M5 grid's existing multi-select drag behavior
(verify there's no gesture collision).
**Option B — "send to bank" / "move to bank" menu** on the current selection (a
context menu or a toolbar dropdown listing banks; the selected samples move to the
chosen bank).
- **Pros:** *precise* — you pick the destination from a named list, no mis-drop. Maps
directly onto the B3 bindable action (the menu is just a visual front-end for the
same `move → bankId` operation, so it's consistent with keyboard/MIDI binding).
Consistent with how the M5 grid already handles selection (act-on-current-selection,
not per-item widgets — the Design View tag/untag already works this way). Scales to
many banks (a menu list handles dozens of destinations fine). Cheapest to build in
the LICE/SWELL surface (a menu, not a drag-and-drop hit-test system).
- **Cons:** less *discoverable* than drag for a first-time user; an extra click vs. a
direct drag; feels less "direct-manipulation" than dragging.
**Option C — both.** Menu as the precise, bindable, discoverable-via-right-click
primary; drag as the direct-manipulation accelerator for users who prefer it.
- **Pros:** covers both interaction styles; the menu carries the bindable/precise
path while drag serves direct manipulation. This is what most media managers do
(Finder, Lightroom, Ableton browser all offer drag *and* a menu/right-click move).
- **Cons:** most implementation cost (build both, and make them agree); drag still
carries its mis-drop risk even as a secondary path.
### Recommendations (pending Daniel's decision)
- **5a — recommend LICE-drawn custom tabs (Option B).** Visual consistency with the
M5 grid and the Design View segmented switch is worth the hand-rolled keyboard-nav
and overflow, *and* it removes the cross-platform SWELL-parity risk entirely (we own
every pixel on all three platforms). The Design View segmented switch is a partial
precedent to extend rather than a from-scratch build. The one thing that would flip
me to native tabs: evidence that users routinely run dozens of named banks, where
built-in overflow earns its keep. Recommend B; revisit if the expected bank count is
high. *Verify SWELL tab-control availability against the headers regardless* — even
choosing B, we should know what the native fallback offers.
- **5b — recommend the "send to bank" menu as primary (Option B), with drag as a
later accelerator (toward C).** The menu is precise, cheap, scales to many banks,
is the natural front-end for the already-bindable B3 move action, and matches how
the M5 grid and Design View already act on the current selection. It sidesteps the
mis-drop hazard that would otherwise fight our "no silent wrong-bank" stance. Drag
is a nice accelerator but should not be the primary or only surface (it can't be
MIDI-bound, and B3 requires the action be bindable). Recommend menu-first; add drag
in a later B4 pass if it earns the cost. Net: ship B, leave the seam for C.