578 lines
35 KiB
Markdown
578 lines
35 KiB
Markdown
# Multi-bank — product notes
|
||
|
||
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
||
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
|
||
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
|
||
narrative, the pool-privilege reasoning, the movement semantics, and the
|
||
design-direction recommendations — so those don't clutter the build docs.
|
||
|
||
Status: framed by product-designer (2026-07-23). All five forks settled by Daniel
|
||
(2026-07-23) — fork 5 (tab rendering + move affordance) is now closed; the record at
|
||
the bottom carries the decision and rationale. Settled forks are folded into the prose
|
||
below. Only the active-bank indicator *placement* remains an open B4 polish detail.
|
||
|
||
---
|
||
|
||
## What it is (and what it is not)
|
||
|
||
**Multi-bank generalizes the single per-project bank into a pool plus named
|
||
banks.** Today ReaSampler has exactly one bank (`BankIndex`) — every capture lands
|
||
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
|
||
every capture still lands in — and lets the user create **named banks** ("Drums",
|
||
"1-Shots", "Synth Hits") that group samples for a purpose. **Move** is the primary
|
||
gesture — a sample relocates from one bank to another, living in exactly one bank at
|
||
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
|
||
pool*, not folders on disk. Moving a sample between banks moves an index entry, not
|
||
a `.wav`. This is deliberate: it keeps movement cheap and non-destructive, and it
|
||
keeps every file exactly where M4's project-relative path resolution already puts
|
||
it. Anyone expecting "move to Drums" to reorganize the bank folder on disk will be
|
||
surprised — and that expectation is headed off in the spec, not discovered in
|
||
review. (See *movement semantics* below.)
|
||
|
||
**It is not a change to how capture works.** The capture pillar's load-bearing rule
|
||
stands untouched: capturing writes a file plus an index entry and never inserts into
|
||
the arrange. Multi-bank changes exactly one thing — *which index* the entry lands
|
||
in. Everything downstream of "add a `Sample` to a `BankIndex`" is unchanged.
|
||
|
||
---
|
||
|
||
## The pool is to banks what Arrange is to modes
|
||
|
||
This phase deliberately reuses the structural pattern Phase D established for modes,
|
||
because the problems are the same shape.
|
||
|
||
In Design View, **Arrange** is structurally just another mode in an N-mode registry,
|
||
but semantically privileged: it is the default home for every untagged leaf. The
|
||
data model is uniform (no Arrange-shaped special type); the *rules layer* enforces
|
||
Arrange's privilege.
|
||
|
||
In Multi-bank, **the pool** is structurally just one `BankIndex` in an N-bank
|
||
registry, but semantically privileged: it always exists, is un-deletable, and is
|
||
un-renamable, and it is the default capture target and the default home for a
|
||
sample that isn't grouped anywhere else. Same move: uniform data model (a bank is a
|
||
bank), privilege enforced in the rules layer (`bank_book` rejects delete-pool /
|
||
rename-pool / zero-banks).
|
||
|
||
Why this over a pool-shaped special type? A special pool type would fork every
|
||
operation into "pool path" and "named-bank path" — serialize, iterate, render,
|
||
move-target-resolution all branch. Treating the pool as bank-zero with three
|
||
enforced rules keeps one code path and one JSON shape, and the privileges live in
|
||
exactly one place. This is the same reasoning that kept Arrange from being a boolean
|
||
special case.
|
||
|
||
---
|
||
|
||
## Why a container, not a bank-id on the sample
|
||
|
||
The obvious alternative to a container is a `bankId` field on `Sample` plus one flat
|
||
`BankIndex`: filter by `bankId` to get a bank's contents. Rejected, for three
|
||
reasons:
|
||
|
||
1. **It modifies `BankIndex`, the tested heart.** The whole discipline is that
|
||
`bank_model` is the pure, hard-tested core. A `bankId` field threads bank
|
||
awareness through the one module that should stay bank-agnostic. A container
|
||
*wraps* `BankIndex` and leaves it byte-for-byte as tested.
|
||
2. **Dedup-by-hash wants per-bank scope.** `BankIndex` already dedups by content
|
||
hash *within itself*. That is exactly the semantics we want per bank — and it is
|
||
what makes copy meaningful (the same hash can live in the pool and in "Drums").
|
||
A flat index with a `bankId` field would need dedup to become "dedup within a
|
||
`bankId` partition," reimplementing per-bank scoping that a container gets for
|
||
free.
|
||
3. **It mirrors the two pure cores we already have.** `bank_model` and
|
||
`view_mode_model` are both "a pure registry with JSON round-trip, unit-tested
|
||
outside the DAW." `bank_book` is the third instance of that exact pattern. A
|
||
`bankId` field would be a fourth, different pattern bolted onto the first.
|
||
|
||
So: `bank_book` is an ordered registry of `{ bank id, display name, ordinal,
|
||
BankIndex }`, pool seeded as bank-zero. Bank id is the stable key (minted GUID-style
|
||
on create); name and ordinal are mutable. Display names are **unique** — two banks
|
||
cannot share a name (compared trimmed + case-insensitively, so "Drums" and "drums"
|
||
are the same name), enforced in the model on create and rename; the pool's "Pool" is
|
||
reserved by the same rule. `BankIndex` is untouched. This is the defer-the-feature,
|
||
design-the-seam principle: the seam is a container above the tested core, not a
|
||
modification of it.
|
||
|
||
---
|
||
|
||
## Movement semantics (the settled rules, in prose)
|
||
|
||
- **Move is index-only, and is the default gesture.** Moving a sample from bank A to
|
||
bank B removes the `Sample` from A's `BankIndex` and adds it to B's. The file never
|
||
moves — one shared file pool under the project bank folder, exactly where M4 put it.
|
||
Cheap, non-destructive, immune to the path-rewrite bug class M4 closed. After a move
|
||
the sample lives in exactly one bank; this is the primary, low-friction affordance
|
||
(see design-direction, below).
|
||
- **Copy is index-only too, and is the deliberate secondary act.** Copy adds the
|
||
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
|
||
`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
|
||
entry — the same collapse `BankIndex::add` already does, now visible across a
|
||
move. On a *move* the source entry is still removed, so the sample ends up in the
|
||
destination once, as expected.
|
||
- **No cross-bank dedup.** The same hash may exist in the pool and in a named bank
|
||
simultaneously. That is the whole point of copy — do not add a global dedup that
|
||
collapses across banks (guardrail in the spec).
|
||
|
||
Why index-only movement over moving files into per-bank subfolders? The subfolder
|
||
approach makes "which bank" a filesystem fact, which reads tidy — but it reintroduces
|
||
exactly the path-rewrite fragility M4 spent three iterations eliminating (Save-As
|
||
relocation, relative-path resolution, GUID-primary identity). Every bank-move would
|
||
be a file operation that has to stay non-destructive and relative-path-correct
|
||
across Save-As. Index-only movement sidesteps all of it: files never move, so paths
|
||
never rewrite, so none of that machinery is re-exercised. Banks are a *view* concept,
|
||
files are a *storage* concept, and keeping them separate is what keeps movement
|
||
trivially correct.
|
||
|
||
---
|
||
|
||
## Persistence and migration
|
||
|
||
The book rides the existing `"reasampler"` project ext-state namespace under a new
|
||
key, `banks`, alongside `view_state` and `project_guid`. Same namespace, same
|
||
travel-with-the-`.rpp` guarantee, same GUID-primary identity and Save-As-relocation
|
||
machinery from M4. One shared physical bank folder; one ext-state namespace.
|
||
|
||
**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
|
||
project saved before this phase has a `bank_index` key and no `banks` key. On load,
|
||
that legacy index becomes *the pool's* `BankIndex`, and the book is `{ pool }` with
|
||
zero named banks. One-way, lossless promotion — no sample is lost, no path changes,
|
||
the user opens their old project and finds everything in the pool exactly as before,
|
||
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.
|
||
|
||
**The migration is a one-time promotion, and it is one-way.** On load: legacy
|
||
`bank_index` present, `banks` absent → promote the legacy index into the pool inside
|
||
a freshly-minted `banks` blob, then treat the blob as authoritative. Once the `banks`
|
||
blob exists it is the only source consulted; the legacy key is not read back or
|
||
kept in sync. A project touched by this version and saved will carry `banks` and
|
||
(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.
|
||
|
||
---
|
||
|
||
## Precision-invariant implications
|
||
|
||
Multi-bank sits *above* the file entirely, so the capture-side invariants
|
||
(determinism, bit-identical repeats, null test, exact bounds) are untouched — they
|
||
are properties of the capture path and the file. The invariants it *does* touch:
|
||
|
||
- **Relative-paths-only** is enforced N times instead of once — once per
|
||
`BankIndex` in the book, by the exact code that enforces it today. Movement adds
|
||
no path handling because files never relocate.
|
||
- **Non-destructive** extends to bank operations: create / rename / delete /
|
||
activate / evacuate and sample move / copy mutate only index + ext-state. No file
|
||
is written, moved, or deleted; no timeline item is touched. In particular,
|
||
**deleting a named bank drops its member index entries but does not delete their
|
||
files** — file lifecycle stays owned by the capture/prune path, never the bank
|
||
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.
|
||
|
||
---
|
||
|
||
## Design-direction (settled forks 2–4, plus rationale)
|
||
|
||
### Move is the default; the pool is a bank, not a permanent mirror
|
||
|
||
**Settled (fork 3): move is the primary, low-friction gesture; copy is the
|
||
deliberate secondary act.** This inverts the earlier framing (which leaned
|
||
copy-as-default on a "pool is the complete library, banks are views" model). Daniel's
|
||
call is a *partition* mental model: a sample lives in one bank at a time, and moving
|
||
it to "Drums" takes it *out* of the pool. Copy remains available for the user who
|
||
genuinely wants a sample in two banks at once, but it is the explicit, deliberate act
|
||
— not the default.
|
||
|
||
This changes what "the pool is the complete library" means, and the docs must be
|
||
honest about it. Under move-as-default the pool is **not** a permanent mirror of
|
||
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
|
||
|
||
Daniel's directive is a vertical split — pool on top, named banks below — with
|
||
full-height toggles for either region. This reads well against the "pool is the
|
||
library, banks are views" model: the library is the persistent top region, the
|
||
curated views are the swappable bottom region.
|
||
|
||
- **Named banks as a tab strip** (one tab per named bank, one bank visible at a
|
||
time in the region) rather than a stack of grids. A tab strip scales to many banks
|
||
without eating vertical space, and "one active tab" pairs with "one active bank"
|
||
cleanly (though *shown* tab and *capture-active* bank are distinct — see below).
|
||
Borrowed from browser/IDE tab strips and sample-library browsers (Ableton's
|
||
collections rail, Kontakt's multi rack).
|
||
- **Full-height toggles** collapse the split to one region: *pool full-height* (hide
|
||
named banks — "I'm just capturing into the pool right now") and *banks full-height*
|
||
(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).
|
||
|
||
### Active bank and shown tab stay distinct (settled fork 4)
|
||
|
||
**Settled (fork 4): the capture-active bank and the currently-shown named-bank tab
|
||
are distinct concepts, and the active bank carries an unmistakable visual
|
||
indicator.** The **capture-active** bank (where new captures land) and the
|
||
**currently-shown** tab are *different* things — you might be looking at "Drums" while
|
||
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.
|
||
|
||
Active bank is an explicit state, set by an explicit "activate" affordance (and
|
||
action), *not* implied by which tab is shown. The pool is active by default;
|
||
activating a named bank is a deliberate act. **The indicator must be visually
|
||
unmistakable** — this is now a settled constraint, not a suggestion. The capture
|
||
target is a thing the user must be able to verify at a glance before hitting capture.
|
||
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 window header already carries the Design View segmented mode switch
|
||
(`[ Arrange | Design ]`). That governs *timeline visibility*. Multi-bank governs
|
||
*sample grouping*. They are fully orthogonal — you can be in Design mode capturing
|
||
into "Synth Hits," or in Arrange mode capturing into the pool. The UI must not
|
||
suggest a coupling: keep the mode switch where it is (header, timeline concern) and
|
||
the bank controls in the bank body (library concern). Do not, for instance, put bank
|
||
tabs next to the mode segments as if they were the same kind of switch.
|
||
|
||
### Tagging vs. banking are different verbs — don't blur them
|
||
|
||
Worth stating because both phases involve "putting a thing into a named group."
|
||
Design View **tags tracks** into modes (a track-visibility concern). Multi-bank
|
||
**moves samples** into banks (a library-organization concern). Different objects
|
||
(tracks vs. samples), different purpose (timeline stance vs. library grouping). The
|
||
vocabulary should stay distinct — "tag into Design" vs. "move to Drums" — so a user
|
||
never conflates the two systems. They rhyme structurally (both are N-collections
|
||
with a privileged default) but they are not the same feature and should not share
|
||
UI metaphors beyond what's genuinely shared.
|
||
|
||
---
|
||
|
||
## Action set (proposed)
|
||
|
||
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.
|
||
|
||
- **Create bank** / **Rename bank** / **Delete bank** — manage the named-bank set.
|
||
(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;
|
||
distinct from browsing a tab.
|
||
- **Move selected samples → bank** / **Copy selected samples → bank** — the two
|
||
movement verbs; move is the low-friction default, copy the deliberate secondary.
|
||
- **Pool full-height** / **Banks full-height** (toggles) — collapse the split.
|
||
|
||
---
|
||
|
||
## Module breakdown (pure / persist / actions / UI)
|
||
|
||
Mirrors the capture and Design View pillars exactly.
|
||
|
||
**Pure `bank_book` (REAPER-free, unit-tested — the mirror of `bank_model` /
|
||
`view_mode_model`):**
|
||
- Ordered bank registry: `{ bank id, display name, ordinal, BankIndex }`; pool
|
||
seeded with fixed id + fixed name.
|
||
- Create / rename / reorder / delete named banks; pool-privilege rules enforced
|
||
here (reject delete-pool, reject rename-pool, never zero banks). Display names are
|
||
unique — create/rename reject a name already used by another bank (trimmed +
|
||
case-insensitive; the pool's "Pool" is protected).
|
||
- 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
|
||
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
|
||
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.
|
||
|
||
**`persist` slice:**
|
||
- Serialize/deserialize the book under the `banks` key in `"reasampler"` (shared
|
||
namespace, distinct section from `view_state`; the pool rides *inside* the `banks`
|
||
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
|
||
exposes the book; the active bank's `BankIndex` is the capture add target.
|
||
|
||
**`actions` entries:** the set listed above (including **evacuate bank → pool**),
|
||
registered with the `command_id` / `gaccel` / `hookcommand` pattern; bank-activate +
|
||
move/copy + evacuate MIDI-bindable.
|
||
|
||
**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
|
||
indicator; create / rename / delete / activate affordances; the sample move/copy
|
||
affordance (drag between regions and/or a "send to bank" menu on selection). Reuses
|
||
the M5 LICE grid render loop per region.
|
||
|
||
---
|
||
|
||
## Settled forks (Daniel, 2026-07-23)
|
||
|
||
All five forks are now decided; their implications are folded into the prose above.
|
||
Recorded here as a scan-line so the decisions don't have to be reconstructed from the
|
||
body text.
|
||
|
||
1. **Persistence key — (a), retire `bank_index`.** The pool folds into the `banks`
|
||
blob as bank-zero; the legacy `bank_index` key is retired after a one-way,
|
||
lossless migration; the blob is authoritative thereafter. (See *persistence and
|
||
migration*.)
|
||
2. **Delete drops members; add an *evacuate* verb.** Deleting a named bank drops its
|
||
member index entries (files untouched, owned by capture/prune). A separate
|
||
*evacuate* operation moves all members back to the pool. Workflow: "evacuate then
|
||
delete" to keep samples; plain delete to drop the grouping and its members.
|
||
Non-empty delete should confirm and offer evacuate. (See *design-direction →
|
||
evacuate*; orphaned-until-prune consequence in *precision-invariant implications*.)
|
||
3. **Move is the default gesture; copy is the deliberate secondary.** A sample lives
|
||
in one bank at a time; copy is the explicit "in two places" escape hatch. (See
|
||
*design-direction → move is the default*.)
|
||
4. **Active bank and shown tab stay distinct; active bank gets an unmistakable
|
||
indicator.** Browsing never retargets capture; the "visually unmistakable"
|
||
requirement is a settled constraint (placement remains a B4 detail). (See
|
||
*design-direction → active bank and shown tab stay distinct*.)
|
||
5. **Named-banks tabs are LICE-drawn; move ships as both menu and drag.** (5a) The
|
||
tab strip is LICE-drawn to match the M5 grid and the Design View segmented switch —
|
||
not a SWELL-native tab control — with an overflow/scroll affordance built in from
|
||
the start so the strip scales past the ~8–12-tab breakdown point. (5b) Move ships as
|
||
*both* a "move to bank" / "send to bank" menu on the current selection (the precise,
|
||
MIDI-bindable front-end for the B3 move action) *and* drag-between-regions (the
|
||
direct-manipulation accelerator); copy stays the deliberate secondary act via the
|
||
menu. Drag mis-drop is mitigated by clear drop-target highlighting and is recoverable
|
||
by design (move is index-only and reversible). (See *Fork 5 — settled* below.)
|
||
|
||
---
|
||
|
||
## Fork 5 — settled (Daniel, 2026-07-23)
|
||
|
||
Two sub-questions, both B4 panel-build mechanics — neither phase-defining, both
|
||
shaping the panel's feel. The full options analysis that produced these calls is
|
||
retained below the decision for the record; the *decision* is what governs B4.
|
||
|
||
**Decision.**
|
||
- **5a — LICE-drawn custom tabs**, matching the M5 grid aesthetic and the Design View
|
||
`[ Arrange | Design ]` segmented-switch precedent. **Overflow/scroll is in scope from
|
||
the start** (chevron or scroll affordance), not deferred — a naive fixed-width LICE
|
||
strip breaks down at ~8–12 tabs, so the panel must scale from B4 onward.
|
||
- **5b — both a "move to bank" menu and drag-between-regions.** The menu is the precise,
|
||
MIDI-bindable front-end for the B3 move action; drag is the direct-manipulation
|
||
accelerator. Copy remains available as the deliberate secondary act, offered on the
|
||
menu. **Drag mis-drop is a designed-for property, not an afterthought:** the
|
||
destination region/tab shows clear drop-target highlighting during a drag, and because
|
||
move is index-only and move-is-default, a mis-drop is *recoverable* (the user moves the
|
||
sample back) rather than destructive — state this as the safety net, not a hope.
|
||
|
||
**Rationale (brief).** 5a: visual consistency across one coherent custom-drawn surface
|
||
plus full cross-platform certainty (we own every pixel, no SWELL tab-parity risk) beats
|
||
buying keyboard-nav/overflow from an inconsistent native widget; the segmented switch is
|
||
a partial precedent to extend. Overflow is pulled forward because banks routinely running
|
||
past a handful is plausible and a backfill after the naive strip ships would be a visible
|
||
cliff. 5b: the menu carries the precise/bindable path (and is the natural front-end for
|
||
the already-bindable B3 action, consistent with how the M5 grid and Design View act on
|
||
the current selection); drag serves users who prefer direct manipulation — the pattern
|
||
every media manager (Finder, Lightroom, Ableton browser) ships. The mis-drop hazard that
|
||
argued against drag-only is defused by highlighting + reversibility, so drag is safe as
|
||
the accelerator.
|
||
|
||
**Verify before building.** LICE tab drawing and any SWELL surface used for the
|
||
menu/drag hit-testing must be verified against the M5 reference and the SWELL/SDK headers
|
||
before use — even having chosen LICE, confirm what a native fallback would offer, and
|
||
confirm the drag hit-test doesn't collide with the M5 grid's existing multi-select drag.
|
||
|
||
---
|
||
|
||
## Fork 5 — options analysis (retained for the record)
|
||
|
||
The analysis that produced the decision above. Kept so the reasoning behind the
|
||
LICE-tabs / both-affordances calls doesn't have to be reconstructed.
|
||
|
||
### 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 ~10–15 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 (~8–12 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 ~6–8 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 that fed the decision (now settled above)
|
||
|
||
These were the product-designer recommendations Daniel weighed; the settled record at
|
||
the top of Fork 5 is authoritative where it differs (notably 5b, which Daniel took all
|
||
the way to "both" with overflow and drop-highlighting pulled forward).
|
||
|
||
- **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.
|