Files
deepdrft/product-notes/phase-17-player-queue-view.md
T

28 KiB
Raw Blame History

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:

  • IQueueService exposes Items (IReadOnlyList<TrackDto>), CurrentIndex, Current, HasNext, HasPrevious, IsArmed, the QueueChanged event, and commands PlayRelease, 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 mutates Items and re-emits QueueChanged"). This phase adds them.
  • The queue is cascaded from AudioPlayerProvider as an IsFixed cascade alongside the player.
  • AudioPlayerBar already subscribes to QueueChanged to re-render skip affordances, and reads HasNext/HasPrevious. Adding a Queue button + panel is the same subscription, more rendering.
  • Fixed (AudioPlayerBar.Fixed) is the embed flag, set true by FramePlayer via EmbedLayout. 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) or Arm/Start (embed). The queue is populated from one release's ordered tracks. There is no hand-assembled multi-source queue yet — Enqueue/EnqueueRange exist 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:

  1. See the queue — an ordered list of what is queued, with the current track marked.
  2. Edit the queue (where editing is allowed) — drag-reorder and per-track removal.
  3. 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 + CurrentIndex and re-emit QueueChanged; 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 to IQueueService; 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. Only PlayRelease/ Start/Next/Previous touch 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 with MudDropContainer now. 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, matching RadialKnob's capturePointer, 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 PlayerTransportZone is a vertical MudStack of (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 bespoke DDIcons queue glyph if Daniel wants the hand-rolled aesthetic — OQ7). Color.Primary to 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 matches NowPlayingCard / 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 on Current (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:
    1. The EmbedLayout / FramePlayer Fixed bar renders the panel inline below the controls.
    2. 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.)

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 / PlaylistAdd with MudTooltip "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).
  • 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 coherent CurrentIndex (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 like PlayRelease/play" are declined — they blur add vs. play.) This engine tweak belongs with wave 17.1 (it is part of the Enqueue-into-empty index handling, alongside the Move/RemoveAt additions). 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
  • ReleaseGallery cards 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 13 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 a TODO.md entry.
  • StreamNowButton is 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) — reorders Items; adjusts CurrentIndex so 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) — removes Items[index].
    • Removing a track after current: just remove; CurrentIndex unchanged.
    • 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 CurrentIndex points 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.

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 > 0 and 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 CurrentIndex such that the next play/skip behaves correctly (per OQ8 resolution).
  • AC9Move and RemoveAt are additive to IQueueService; all existing QueueServiceTests pass 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; Items reflects new order; QueueChanged fired once.
  • T2 Move keeps the current track current: with CurrentIndex at track X, moving other items around it leaves Current == X; moving X itself updates CurrentIndex to X's new slot.
  • T3 Move does not call the player's stream method (assert fake player saw no SelectTrackStreaming).
  • T4 RemoveAt after current: CurrentIndex unchanged, item gone.
  • T5 RemoveAt before current: CurrentIndex decremented, same track current.
  • T6 RemoveAt of current: player not stopped (per §6); CurrentIndex resolves to the new occupant or -1 if it was last.
  • T7 RemoveAt of last remaining item → empty + dormant (CurrentIndex == -1, Items empty, QueueChanged fired).
  • T8 Move/RemoveAt out-of-range / no-op cases do not throw and do not fire QueueChanged.
  • T9 Enqueue into 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.ForRelease height differs from ForTrack (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 → MudDropContainer for now. Ship with MudBlazor's MudDropContainer. If mobile/touch issues surface in practice, pivot to a touch-viable mechanism later (pointer-based interop reorder matching RadialKnob's capturePointer, 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 Clear does 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-rolled DDIcons queue 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 — ReleaseGallery cards. 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 in TODO.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/RemoveAt to IQueueService/ QueueService (+ QueueServiceTests). Plus the OQ8 affordance: Enqueue into a dormant/empty queue leaves a coherent CurrentIndex (pure append, no auto-play) so the next play/skip is correct. Build a single presentational QueueList component that renders Items with the current marked and takes an Editable flag (drag/remove on when true; reorder via MudDropContainer per 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, hosting QueueList 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.ForRelease height 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 existing Enqueue/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 (OQ1OQ11) 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.