28 KiB
Phase 17 — Player-Bar Queue View
Status: design spec — 4 of 11 open questions resolved (Daniel, 2026-06-19); 7 still pending. Author: product-designer. Date: 2026-06-19. Plan only — no code has been written by this doc.
Resolved (Daniel, 2026-06-19): OQ1 → Option A, conditional (collapse/expand toggle if dynamic
iframe resize is achievable in the embed snippet; else fall back to Option B, omit the button —
feasibility call made during 17.3). OQ4 → MudDropContainer for now (pivot to a touch-viable
mechanism later if mobile issues surface; C6 softened — touch-viability is a known risk, not a pre-ship
blocker). OQ8 → pure append, confirmed (add ≠ play; first add into a dormant queue leaves a coherent
CurrentIndex via a small additive engine affordance, landing with wave 17.1). OQ10 → deferred,
confirmed (cards get no Add-to-Queue in Phase 17; the deferred card work is captured in TODO.md).
The remaining seven (OQ2, OQ3, OQ5, OQ6, OQ7, OQ9, OQ11) are still pending — their §10
recommendations stand but are not confirmed.
This phase makes the play-queue visible and manipulable. Phase 11 (wave 11.F) built the queue
engine (IQueueService / QueueService) and wired auto-advance, skip-prev/next, and release
embeds — but the queue itself has no UI. A listener can start an album and skip through it, but
cannot see what is next, reorder it, drop a track, or hand-build a queue. This phase surfaces it.
Cross-references (read before implementing):
PLAN.md §17— the concise phase entry.DeepDrftPublic.Client/Services/IQueueService.cs/QueueService.cs— the engine this phase drives. Its docstrings already anticipate reorder + removal as additive members (see §1).DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor[.cs]— the bar that gains the Queue button + (Fixed) the embedded panel.DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor/PlayerTransportZone.razor— where transport buttons live; the Queue button sits relative to these.DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor[.cs]— cascades the queue (IsFixed). Player + queue state stays here, at layout level (project memory; do not move into pages).DeepDrftPublic.Client/Pages/FramePlayer.razor+Layout/EmbedLayout.razor— the Fixed/embed surface; the iframe-host sizing constraint lives here (§4).DeepDrftPublic.Client/Controls/SharePopover.razor/WaveformVisualizerControlPopover.razor— the established overlay/dismissal idioms to borrow for the non-Fixed overlay (§3).DeepDrftPublic.Client/Pages/CutDetail.razor,Controls/ReleaseHeroOverlay.razor,Controls/ReleaseGallery.razor,Controls/StreamNowButton.razor— the play-affordance sites the "Add to Queue" button (§5) attaches beside.
0. State it inherits (verified 2026-06-19)
The queue engine is mature and complete for orchestration:
IQueueServiceexposesItems(IReadOnlyList<TrackDto>),CurrentIndex,Current,HasNext,HasPrevious,IsArmed, theQueueChangedevent, and commandsPlayRelease,Arm,Start,Enqueue,EnqueueRange,Next,Previous,Clear. There is no reorder or remove member yet — but the interface docstring explicitly reserves them as additive ("reordering mutatesItemsand re-emitsQueueChanged"). This phase adds them.- The queue is cascaded from
AudioPlayerProvideras anIsFixedcascade alongside the player. AudioPlayerBaralready subscribes toQueueChangedto re-render skip affordances, and readsHasNext/HasPrevious. Adding a Queue button + panel is the same subscription, more rendering.- Fixed (
AudioPlayerBar.Fixed) is the embed flag, set true byFramePlayerviaEmbedLayout. In Fixed mode the bar is in normal flow (no minimize/spacer/clip); the host iframe is sized by the embed snippet (EmbedSnippetBuilder). Today a release embed arms a shared queue; a single-track embed leaves the queue empty. - "Shared queue" today = a multi-track release loaded via
PlayRelease(docked) orArm/Start(embed). The queue is populated from one release's ordered tracks. There is no hand-assembled multi-source queue yet —Enqueue/EnqueueRangeexist on the interface but no UI calls them.
The gap this phase closes: the queue is a black box. You can drive it (skip) but not inspect or edit
it. Item 4 ("Add to Queue") also activates the dormant Enqueue path — the first time the queue
becomes a thing a listener builds, not just a release they play.
1. Goal
Make the queue a first-class, visible, editable object in the player bar — without moving any player or queue state out of the layout-level provider, and without changing the streaming/playback seam.
Three user-visible capabilities, gated by a single new Queue toggle button in the bar:
- See the queue — an ordered list of what is queued, with the current track marked.
- Edit the queue (where editing is allowed) — drag-reorder and per-track removal.
- Grow the queue — an "Add to Queue" affordance everywhere there is a play button.
The two view modes (overlay vs. embedded panel) differ only in presentation and edit-permission,
not in the data they read — both bind the same cascaded IQueueService.Items (project memory: one
source, multiple views).
2. Constraints (hard)
- C1 — Player/queue state stays at layout level. The Queue view reads the cascaded
IQueueService; it does not own or relocate queue state. No page-level player state. (Project memory.) - C2 — No new playback semantics. Reorder/remove mutate
Items+CurrentIndexand re-emitQueueChanged; they must not restart, re-stream, or interrupt the currently-playing track (matching the engine's existing "the player stays a single-track device" posture). Reordering the currently-playing item or items around it must keep playback uninterrupted. - C3 — Embed = read-only shared queue. In Fixed (embed) mode, a shared (release) queue is fixed-order and non-editable: no drag, no remove. The panel is a display of the up-next list.
- C4 — The engine is extended additively, never reshaped. New members (
Move/RemoveAt) are added toIQueueService; existing members and their contracts are untouched. Consumers written against today's surface keep working (the interface's stated open/closed posture). - C5 — Reorder/remove are interop-free state mutations. Like
Enqueue/Arm, they touch only the in-memory list + index +QueueChanged. They run safely during prerender. OnlyPlayRelease/Start/Next/Previoustouch JS. - C6 — Drag-and-drop should be touch-viable (softened — OQ4 resolved). The public site's primary
listening surface includes mobile (per
PLAN.md §1.7). Resolved (Daniel, 2026-06-19): ship withMudDropContainernow. Touch-viability is no longer a hard pre-ship blocker — it is a known risk with a planned pivot path: if mobile/touch issues surface in practice, pivot to a touch-viable mechanism (pointer-interop reorder, matchingRadialKnob'scapturePointer, or an up/down-arrow fallback) in a later pass. See OQ4 (resolved). - C7 — Embedded panel must not break the iframe host. Adding the panel in Fixed mode grows the player's intrinsic height; the embed snippet's iframe dimensions must account for it (§4, OQ6).
3. Item 1+2 — Queue button & non-Fixed (overlay) mode
3.1 The Queue button
A new icon button in the player bar, shown only when a queue is loaded (Items.Count > 0).
Mirror the existing skip-affordance gating: with no queue (null) or empty queue, the button is
absent, so the bar is byte-for-byte its pre-queue self for single-track play.
- Placement (Daniel): to the left of the timestamps and below the other player control
buttons. Today
PlayerTransportZoneis a verticalMudStackof (transport-button row) over (TimestampLabel). The Queue button sits between those two — a second row below the play/skip controls and above the timestamp. That literally satisfies "below the control buttons, to the left of the timestamps." - Glyph:
Icons.Material.Filled.QueueMusic(or a bespokeDDIconsqueue glyph if Daniel wants the hand-rolled aesthetic — OQ7).Color.Primaryto match the transport row. - Active state: when the overlay/panel is open, the button reads active (filled/highlighted), matching the visualizer popover idiom.
3.2 Non-Fixed overlay panel
When the bar is not Fixed (the docked public player), clicking Queue toggles a screen-centered
overlay — borrow the exact idiom Phase 15 settled on for the visualizer controls: MudOverlay
(DarkBackground, Modal), panel stops click propagation, scrim-click closes, drag-safe (a
position:fixed drag capture sits above the scrim so a drag that ends outside the panel does not
dismiss). Not a MudPopover anchored to the bar — a centered modal is the proven pattern here
and avoids anchor math against a position:fixed dock.
- Shape: "mostly square" (Daniel) — a panel roughly
min(90vw, 520px)square, scrollable track list inside. Chrome matchesNowPlayingCard/ the visualizer panel (square corners, lighter-navy, thin border) for family resemblance. - Contents: ordered list of
Items. Each row: drag handle, track number/position, track name + artist, a now-playing marker onCurrent(index ==CurrentIndex), a remove (×) button. Clicking a row's body jumps playback to that track (PlayRelease(Items, thatIndex)— reuses the existing "play from index" semantics; OQ2 confirms this is desired vs. a dedicated jump). - Reorder: drag-and-drop reordering of rows →
IQueueService.Move(from, to)(new member, §6). - Remove: per-row × →
IQueueService.RemoveAt(index)(new member, §6). - Header: title ("Queue" / "Up Next"), optional "Clear" action (calls existing
Clear(); OQ5 — does Clear belong in the UI, and does it stop playback?). - Empty state: the button is hidden when empty, so the overlay is never opened empty by the button. But removal can empty a non-empty queue mid-session — define the terminal state (OQ3).
3.3 Borrowed precedent
The overlay shell, dismissal, and drag-safety are a direct lift of
WaveformVisualizerControlPopover (Phase 15 §4). The "mostly square panel, family chrome" is the
NowPlayingCard look. Reuse, don't reinvent.
4. Item 3 — Fixed (embedded) mode
When the bar is Fixed (the /FramePlayer iframe embed), the queue list is always shown below
the existing player-bar controls — not a toggle overlay. It is part of the embed's standing layout.
- Permissions (C3): the embedded shared (release) queue is fixed-order, non-editable — no drag handles, no remove buttons. It is a read-only up-next display: ordered track list, current track marked, future tracks listed. Tapping a future row may jump to it (OQ2 — even read-only queues could allow jump-to-track; reorder/remove are the forbidden ops, jump is arguably fine).
- Container resize (C7): the Fixed bar gains vertical height for the panel. Two coupled changes:
- The
EmbedLayout/FramePlayerFixed bar renders the panel inline below the controls. EmbedSnippetBuilder.ForRelease(...)must mint an iframe tall enough to show the panel.ForTrack(...)stays at today's compact height (single-track embeds have no queue, so no panel). This makes the two embed snippets diverge in height — track-embed compact, release-embed taller. That is correct: only release embeds have a queue to show. (OQ6 — fixed taller height for all release embeds, vs. a height that scales with track count up to a cap. Recommend: a fixed sensible height with the panel internally scrollable past N rows.)
- The
4.1 The Queue button in embed mode (Daniel asked to clarify)
Recommendation: in Fixed mode the panel is always shown, so the Queue button is not needed for toggling — but rather than hide it, repurpose it as a collapse/expand control for the panel so a listener can reclaim the iframe space. Two viable readings, pick one:
- Option A (resolved — preferred) — collapse toggle. Queue button is present in Fixed mode and collapses/expands the always-default-open panel. Default = expanded. Gives the embedder's viewer control. Consistent button meaning across modes ("toggle the queue view").
- Option B (fallback) — omit the button in Fixed mode entirely. Panel is always open, no toggle. Simpler, but the button's meaning then differs by mode (present+toggles-overlay when docked, absent when embedded), and a viewer can't reclaim space.
OQ1 — RESOLVED (Daniel, 2026-06-19): Option A, conditional on feasibility. Use Option A if
the iframe can be dynamically resized appropriately for collapse/expand — e.g. an embed-snippet
postMessage → host resize handshake so the host iframe shrinks when the panel collapses and grows
when it expands. Otherwise fall back to Option B (omit the button, panel always open at fixed
height). A is preferred; B is the fallback; the deciding factor is whether dynamic iframe resize is
achievable in the embed snippet. This A-vs-B determination is made during 17.3 implementation —
record it there. Note the interaction with OQ6 (embed panel height): a collapse/expand that does not
resize the iframe (the earlier "iframe stays sized for expanded; collapsed just shows less" reading) is
not Option A under this resolution — Option A requires the iframe to actually resize. A non-resizing
collapse is cosmetic and does not let the viewer reclaim space, so it collapses into Option B's value.
5. Item 4 — "Add to Queue" affordance
An "Add to Queue" icon button with a tooltip beside every play button for a release or track.
- Glyph + tooltip:
Icons.Material.Filled.QueueMusic/PlaylistAddwithMudTooltip"Add to queue" (track) / "Add release to queue" (release). (OQ7 — Material vs. bespoke glyph.) - Behavior:
- Track context:
IQueueService.Enqueue(track)— appends without disturbing current playback (existing member, no UI calls it today; this lights it up). - Release context:
IQueueService.EnqueueRange(orderedTracks)— appends the whole release's ordered track list (existing member).
- Track context:
- First-add semantics — RESOLVED (Daniel, 2026-06-19): pure append (option (a)). Add-to-Queue is
explicitly not play; keep the verbs distinct. If the queue is empty/dormant (
CurrentIndex == -1), the first add appends and leaves a coherentCurrentIndex(the small additive engine affordance) so the next play/skip behaves correctly — but does not auto-play. The listener still presses play to start. (Options (b) "first add also stages/sets current without auto-playing" and (c) "first add behaves likePlayRelease/play" are declined — they blur add vs. play.) This engine tweak belongs with wave 17.1 (it is part of theEnqueue-into-empty index handling, alongside theMove/RemoveAtadditions). See OQ8 (resolved).
5.1 Where the button attaches (inventory)
Every site that currently has a play affordance:
| Site | Current play affordance | Add-to-Queue target |
|---|---|---|
CutDetail header |
"Play" MudButton (whole album) |
release → EnqueueRange |
CutDetail track rows |
per-row PlayStateIcon |
track → Enqueue |
SessionDetail / MixDetail |
PlayContent slot in ReleaseHeroOverlay (single track) |
track → Enqueue |
StreamNowButton |
random-track stream trigger | edge case — OQ9 (random has no fixed track until resolved) |
ReleaseGallery cards |
none today (cards are pure links) | scope question — OQ10 |
ReleaseGallerycards have no play button today — they navigate to detail. Adding an Add-to-Queue button there is new affordance, out of the literal brief ("anywhere there is a play button"). OQ10 — RESOLVED (Daniel, 2026-06-19): deferred, confirmed. Cards get no Add-to-Queue affordance in Phase 17. Scope item 4 to the detail-page play sites (rows 1–3 above) where a play button genuinely exists today. The deferred work — giving cards a play + Add-to-Queue affordance (a card-redesign question) — is captured as aTODO.mdentry.StreamNowButtonis a "surprise me" trigger, not a fixed track — Add-to-Queue has no concrete item until a track resolves. Recommend: exclude it from item 4. OQ9.
So the grounded scope of item 4: the CutDetail header + track rows, and the Session/Mix hero
play slot. That is "every play button that targets a known release or track."
6. Engine additions (the only IQueueService changes)
Two new members, both interop-free state mutations that re-emit QueueChanged:
void Move(int fromIndex, int toIndex)— reordersItems; adjustsCurrentIndexso the same track stays current across the move (find current track's new position). No re-stream (C2). No-op on out-of-range or equal indices.void RemoveAt(int index)— removesItems[index].- Removing a track after current: just remove;
CurrentIndexunchanged. - Removing a track before current: remove + decrement
CurrentIndex(current track stays the same item, new lower index). Playback uninterrupted. - Removing the current track: define — recommend stop is not triggered (C2 says mutations
don't touch playback), so the playing track keeps playing to its natural end even though it's no
longer in the list, and
CurrentIndexpoints at what is now at that slot (the next track) so the next auto-advance / skip is coherent. Edge: removing current when it's the last item → queue becomes shorter; auto-advance simply has nothing to advance to (existing behavior). OQ3/OQ11. - Removing the last remaining track → empty queue (
CurrentIndex = -1, dormant). The Queue button disappears; if the overlay is open it closes (or shows empty state then closes). OQ3.
- Removing a track after current: just remove;
Both are additive (C4). Unit-testable against the existing QueueServiceTests fake-player
harness with no container (the engine's stated design intent).
7. Use cases
- UC1 — Inspect up-next (docked). Listener playing an album opens the Queue overlay, sees the ordered remaining tracks with the current one marked, closes it. No edit.
- UC2 — Reorder (docked). Listener drags track 5 above track 2; playback continues uninterrupted; the new order drives the next auto-advance.
- UC3 — Remove (docked). Listener removes a track they don't want; if it was upcoming, it's gone from the up-next; if it was current, current keeps playing to natural end (per §6).
- UC4 — Build a queue (docked). Listener on a Cut detail page clicks "Add to Queue" on three individual tracks across two albums, then opens the queue and plays from the top.
- UC5 — Embed up-next (Fixed). A blog embeds a release; the iframe shows the player and the fixed-order track list below it; viewer sees what's coming, cannot reorder/remove; (Option A) collapses the panel to reclaim space.
- UC6 — Single-track embed unchanged. A single-track embed shows no Queue button and no panel — the compact iframe is byte-for-byte today's.
- UC7 — Jump to track. Listener clicks a queued row → playback jumps to that track and continues from there (if OQ2 = yes).
8. Acceptance criteria
- AC1 — When
Items.Count == 0, the Queue button is absent and the docked bar renders exactly as it does today (no layout shift, no empty panel). - AC2 — When
Items.Count > 0and not Fixed, the Queue button appears below the transport controls and to the left of the timestamp; clicking it opens a centered, mostly-square overlay listing the queue with the current track marked. - AC3 (docked) — Drag-reordering a row changes the queue order and is reflected in subsequent skip/auto-advance; the currently-playing track is not re-streamed or interrupted by any reorder.
- AC4 (docked) — Removing an upcoming track removes it from the list and from auto-advance; removing a track before the current one keeps the same track playing; removing the current track does not stop playback (per §6 decision).
- AC5 (Fixed) — In a release embed, the queue panel renders below the controls by default, shows the ordered tracks with the current marked, and exposes no drag handles or remove buttons.
- AC6 (Fixed) — The release embed iframe (via
EmbedSnippetBuilder.ForRelease) is tall enough to show the panel without clipping; the single-track embed (ForTrack) height is unchanged. - AC7 — An "Add to Queue" button with tooltip appears beside the Cut header Play, each Cut track row's play, and the Session/Mix hero play; clicking it appends the track/release to the queue without disturbing the currently-playing track.
- AC8 — Add-to-Queue on a dormant (empty) queue leaves the queue populated with a coherent
CurrentIndexsuch that the next play/skip behaves correctly (per OQ8 resolution). - AC9 —
MoveandRemoveAtare additive toIQueueService; all existingQueueServiceTestspass unchanged; no existing member's behavior changes. - AC10 — All queue-view rendering and the new engine mutations run during prerender without JS interop (no prerender exceptions); only playback transitions touch JS.
9. Test cases
Engine (QueueServiceTests, fake-player harness — no container):
- T1
Move(2, 0)on a 4-item queue reorders correctly;Itemsreflects new order;QueueChangedfired once. - T2
Movekeeps the current track current: withCurrentIndexat track X, moving other items around it leavesCurrent== X; moving X itself updatesCurrentIndexto X's new slot. - T3
Movedoes not call the player's stream method (assert fake player saw noSelectTrackStreaming). - T4
RemoveAtafter current:CurrentIndexunchanged, item gone. - T5
RemoveAtbefore current:CurrentIndexdecremented, same track current. - T6
RemoveAtof current: player not stopped (per §6);CurrentIndexresolves to the new occupant or-1if it was last. - T7
RemoveAtof last remaining item → empty + dormant (CurrentIndex == -1,Itemsempty,QueueChangedfired). - T8
Move/RemoveAtout-of-range / no-op cases do not throw and do not fireQueueChanged. - T9
Enqueueinto a dormant queue then play behaves per OQ8 resolution. - T10 Reordering the currently-playing item to a new slot leaves auto-advance pointing at the correct next track when the current ends.
Component (if/when component test coverage is in scope — today none exists for the client, per
DeepDrftPublic.Client/CLAUDE.md):
- T11 Queue button absent when queue empty (render assertion).
- T12 Overlay opens/closes via button + scrim; a simulated drag that ends on the scrim does not dismiss.
- T13 Fixed-mode panel renders inline with no drag/remove controls.
- T14
EmbedSnippetBuilder.ForReleaseheight differs fromForTrack(unit test on the builder).
10. Open questions for Daniel
Four resolved (Daniel, 2026-06-19): OQ1, OQ4, OQ8, OQ10. Seven still pending: OQ2, OQ3, OQ5, OQ6, OQ7, OQ9, OQ11 — their recommendations below stand but are not confirmed.
- OQ1 — Queue button in embed (Fixed) mode. RESOLVED → Option A, conditional. Use Option A
(collapse/expand toggle) if the iframe can be dynamically resized for collapse/expand (embed-snippet
postMessage→ host resize handshake); else fall back to Option B (omit the button). A preferred, B fallback; deciding factor = whether dynamic iframe resize is achievable in the embed snippet; determination made during 17.3 (see §4.1). - OQ2 — Click-to-jump. Does clicking a queued row jump playback to that track? Recommend yes
in both modes (reuses
PlayRelease(Items, index)); jump is not a forbidden edit, only reorder/ remove are. Confirm for the read-only embed too. - OQ3 — Terminal/empty states. When removal empties the queue mid-session: close the overlay? Show an empty state then auto-close? And when the current track is removed, what should the panel and the player show?
- OQ4 — Drag-and-drop mechanism & touch. RESOLVED →
MudDropContainerfor now. Ship with MudBlazor'sMudDropContainer. If mobile/touch issues surface in practice, pivot to a touch-viable mechanism later (pointer-based interop reorder matchingRadialKnob'scapturePointer, or an up/down-arrow fallback). C6 softened: touch-viability is a known risk with a planned pivot path, not a hard pre-ship blocker (see §2 C6). - OQ5 — "Clear queue" in the UI. Include a Clear action in the overlay header? Does Clear stop
playback or just empty the up-next (engine's
Cleardoes not stop the player today)? - OQ6 — Embed panel height. Fixed sensible height with internal scroll past N rows (recommend),
vs. height that grows with track count up to a cap. Affects
EmbedSnippetBuilder.ForRelease. - OQ7 — Glyph. Material
QueueMusic/PlaylistAdd, or a bespoke hand-rolledDDIconsqueue glyph to match the gas-lamp/lava-lamp family aesthetic? - OQ8 — Add-to-Queue into a dormant (empty) queue. RESOLVED → pure append. Add ≠ play; first add
into a dormant queue appends and leaves a coherent
CurrentIndex(small additive engine affordance) so the next play/skip behaves correctly, but does not auto-play. The engine tweak lands with wave 17.1. (first-add-stages and first-add-plays both declined — see §5.) - OQ9 —
StreamNowButton. Exclude from Add-to-Queue (recommend — no fixed track until resolved)? - OQ10 —
ReleaseGallerycards. RESOLVED → deferred, confirmed. Browse-grid cards get no Add-to-Queue affordance in Phase 17. Item 4 is scoped to the detail-page play sites (Cut header, Cut track rows, Session/Mix hero). The deferred card work (play + Add-to-Queue, a card-redesign question) is captured inTODO.md. - OQ11 — Removing the current track. Confirm the recommended "keep playing to natural end, don't stop" behavior (C2-consistent) vs. "removing current skips to next immediately."
11. Wave decomposition (implementation sequencing)
Three waves; 17.1 is the load-bearing prerequisite (engine + the shared queue-list view that both modes render). 17.2 and 17.3 then hang off it and are largely parallel.
- 17.1 — Engine additions + shared queue-list view. Add
Move/RemoveAttoIQueueService/QueueService(+QueueServiceTests). Plus the OQ8 affordance:Enqueueinto a dormant/empty queue leaves a coherentCurrentIndex(pure append, no auto-play) so the next play/skip is correct. Build a single presentationalQueueListcomponent that rendersItemswith the current marked and takes anEditableflag (drag/remove on when true; reorder viaMudDropContainerper OQ4). This is the one source both modes consume. Cold-start. Gates 17.2 + 17.3. No bar changes yet. - 17.2 — Non-Fixed overlay. Queue button in the bar (gated on
Items.Count > 0, placed per §3.1), the centered overlay borrowing the visualizer-popover idiom, hostingQueueList Editable. Reorder/remove/jump wired. Depends on 17.1. - 17.3 — Fixed embed panel + snippet resize + Add-to-Queue. Inline
QueueList(non-editable) below the Fixed bar; the OQ1 feasibility determination is made here — implement Option A (collapse/expand button + iframe resize handshake) if dynamic iframe resize proves achievable in the embed snippet, else fall back to Option B (omit the button);EmbedSnippetBuilder.ForReleaseheight bump; the Add-to-Queue buttons at the detail-page play sites (§5.1 grounded scope — cards excluded per OQ10). Depends on 17.1. (Add-to-Queue could split into its own wave 17.4 if Daniel wants it shipped independently — it only needs the existingEnqueue/EnqueueRange, not 17.1's new members.)
Dependency shape: 17.1 → {17.2, 17.3}. The cold-start item is 17.1. Most open questions
(OQ1–OQ11) must be resolved before 17.2/17.3 land, but 17.1 can begin immediately — the engine
contract for Move/RemoveAt is settled (§6) and independent of the UI decisions.