# COMPLETED.md — DeepDrftHome Archive of items that have moved out of `PLAN.md` and `CMS-PLAN.md`. Per `CONTEXT.md §6`, completed items are moved here rather than deleted. Each entry preserves the original "What / Why / Shape" body so this file reads as a decision record, not just an outcome list. Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CMS-PLAN.md` themes) when there are enough entries to warrant it. --- ## Phase 17 — Player-Bar Queue View: Wave 17.1 — Engine additions + shared QueueList (landed 2026-06-19) **Landed:** 2026-06-19 on dev. - **What:** The queue-engine additions and the shared presentational list component that waves 17.2 and 17.3 will consume. No player-bar wiring, overlay, embed, or Add-to-Queue affordance landed — those remain in waves 17.2 and 17.3. - **Why:** Wave 17.1 is the cold-start prerequisite for the full Phase 17 queue-view surface. The engine additions are interop-free state mutations that land without any UI decision being made; `QueueList` is the single presentational "view" both the docked overlay and the embedded panel will share (one source, multiple views), so it is cleanest to build and test it before the hosting contexts exist. - **Shape:** - **Engine — `Move(int fromIndex, int toIndex)`** (`IQueueService` / `QueueService`): reorders `Items` in-place, adjusts `CurrentIndex` so the same track stays current across the move, re-emits `QueueChanged`. Never re-streams or interrupts the playing track. No-op (no `QueueChanged`) when either index is out of range or the indices are equal. Interop-free; safe during prerender. - **Engine — `RemoveAt(int index)`** (`IQueueService` / `QueueService`): removes the item at `index`, adjusts `CurrentIndex` (a track before current decrements the index; a track after current leaves it unchanged; removing the current track does not stop playback — the track runs to natural end while `CurrentIndex` resolves to the new slot occupant; removing the last remaining item leaves the queue empty and dormant with `CurrentIndex == -1`). Re-emits `QueueChanged`. No-op when `index` is out of range. Interop-free; safe during prerender. - **Engine — dormant-`Enqueue` coherence (OQ8):** `Enqueue` and `EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) now set `CurrentIndex` to 0 so a subsequent play/skip is correct. Does **not** auto-play — add is not play. `PlayCurrent` is never called from these paths; the methods remain interop-free. - **`QueueList.razor`** (`DeepDrftPublic.Client/Controls/QueueList.razor`): purely presentational component. Renders `Items` as an ordered list with the current track marked (position number + `GraphicEq` now-playing icon on the current row). `Editable` flag gates drag-reorder handles and per-row remove controls: when `true`, wraps rows in a `MudDropContainer`/`MudDropZone` for reorder; when `false`, renders a plain `
` (read-only; the embed's fixed-order shared queue). Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback OnRemove`, and `EventCallback OnJump` respectively — the component calls no `IQueueService` method itself. Owns no data fetch or player wiring. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs). - **`QueueServiceTests`**: T1–T10 added, covering `Move` (in-range, out-of-range, same-index no-ops; current-track identity preserved across reorders) and `RemoveAt` (before/after/at current; last-item dormant; out-of-range no-op; playback not stopped). --- ## Phase 16 — Anonymous Play & Share Tracking: Wave 16.1 — Foundation (landed 2026-06-19) **Landed:** 2026-06-19 on dev. - **What:** The anonymous telemetry **substrate** — foundation end-to-end with nothing reading it yet. No `anonId` written; no home-card/read surface changed (those are waves 16.3 and 16.5). The full capture-and-storage pipeline is in place: client-side play-session tracker and share tracker, `sendBeacon` transport with page-unload handler, proxied and rate-limited intake endpoints, append-only SQL event log with incremental rollup, and server-side release attribution. - **Why:** The home hero's Plays stat card (`NowPlayingStats.razor`'s third card) has been a static "XXX / Plays (Coming Soon)" placeholder. Phase 16 builds the anonymous, privacy-light substrate that will eventually power it. Wave 16.1 is the cold-start foundation — nothing reads the log yet; correctness and storage are the deliverable, not the visible metric. - **Shape:** - **Client — `PlayTracker`** (`DeepDrftPublic.Client/Services/PlayTracker.cs`): opens a play session on playback start, advances a high-water position on each progress tick (instrumented at `StreamingAudioPlayerService`, not at the HTTP layer, so seek-beyond-buffer re-fetches count as the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3s OR ≥5% of duration (whichever is smaller). Three-bucket classification: `partial` < 30%, `sampled` 30–80%, `complete` > 80%. Emits at most one event per session via `IPlayEventSink`. Deliberately free of player, HTTP, and JS dependencies for testability. - **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce so repeated copies of the same link in a session count as one share. Sends via `sendBeacon`. No `anonId` in wave 16.1. - **Client — `BeaconInterop`** (`DeepDrftPublic.Client/Services/BeaconInterop.cs`): `navigator.sendBeacon` JS interop wrapper + page-unload handler that flushes any pending play event when the page is torn down. - **Public proxy — `EventProxyController`** (`DeepDrftPublic/Controllers/EventProxyController.cs`): proxies `POST api/event/play` and `POST api/event/share` to DeepDrftAPI. Buffers and relays the small JSON body verbatim; forwards `X-Forwarded-For` for per-IP rate limiting on the API side. Opts out of antiforgery (`[IgnoreAntiforgeryToken]`) — `sendBeacon` cannot attach tokens. - **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `POST api/event/play` and `POST api/event/share`, unauthenticated, rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, registered in `Program.cs`). Returns `202 Accepted` (fire-and-forget contract). Payload-validates the track key and enum values; delegates writes to `IEventService`. - **API — rate limiter** (`DeepDrftAPI/Program.cs`): `AddRateLimiter` + `"events"` fixed-window policy keyed on `Connection.RemoteIpAddress`; `UseForwardedHeaders` in production resolves the XFF chain into the real client IP. `UseRateLimiter()` added to the middleware pipeline. - **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): append-only writes to `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository stamps the release id. - **Data — `EventManager` / `IEventService`** (`DeepDrftData/EventManager.cs`): `IEventService` boundary (`RecordPlay`, `RecordShare`); `EventManager` wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs` alongside the existing track and release domain services. - **Migration `20260619155610_AddPlayShareTelemetry`**: adds `play_event`, `share_event`, and `play_counter` tables. **Authored but not yet applied** (Daniel-gated). --- ## Phase 16 — Anonymous Play & Share Tracking: Wave 16.3 — Unique-listener `anonId` layer (landed 2026-06-19) **Landed:** 2026-06-19 on dev (merge `297805b`). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the wave 16.1 migration. - **What:** The unique-listener `anonId` seam end-to-end — the "last metric layer" of the Phase 16 substrate. Client mints a first-party `localStorage` GUID on first visit, threads it onto play and share beacon payloads (omitted when null), server accepts and length-clamps it (reject-not-truncate, ≤64 chars), persists it to the reserved nullable `anon_id` columns, and exposes all-time distinct-listener aggregation. The distinct-count capability is in place but not yet surfaced on any read surface (16.5 consumes it). Privacy-notice copy deliberately not authored (Daniel-gated). - **Why:** The anonymous unique-listener metric (D5 / D3) is the final substrate wave before the home Plays card can be lit (16.5). It was sequenced last of the metric layers because it is the lowest-priority metric and carries no dependency — the event log captures `anon_id` on the same rows 16.1 already writes; 16.3 simply lights the seam that was reserved but unused. - **Shape:** - **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`. - **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable. - **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically). - **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink. - **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through. - **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): three new distinct-count queries (already in the repository as of 16.3): `CountDistinctListenersAsync()` (site-wide, nulls excluded), `CountDistinctListenersForTrackAsync(trackEntryKey)` (per-track), `CountDistinctListenersForReleaseAsync(releaseId)` (per-release, uses the stamped `release_id` on the play event row — D4 attribution). - **Data — `IEventService` / `EventManager`** (`DeepDrftData/EventManager.cs`): three new members exposing the distinct-count capability: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)` — each returns `ResultContainer`. No read surface or card consumes them yet (16.5). - **No migration** — the `anon_id varchar(64)` columns on `play_event` and `share_event` and their covering indexes (`IX_play_event_anon_id`, `IX_share_event_anon_id`) were already created by `20260619155610_AddPlayShareTelemetry` (wave 16.1). Wave 16.3 only wires the client seam and adds the server-side aggregation queries. --- ## Phase 16 — Anonymous Play & Share Tracking: Wave 16.2 — Completion-bucket classification + shares (landed by absorption into 16.1) **Landed:** absorbed into wave 16.1 (2026-06-19). All §4.1 deliverables shipped inside the foundation wave. - **What:** Three-bucket completion classification correct and exhaustive end to end, and share-channel split. Because these were structurally inseparable from the foundation (the tracker, payload, log table, and rollup all required the bucket column set from day one), they landed together with 16.1 rather than as a follow-on wave. - **Why:** The §4.2 spec listed bucket classification and share-channel split as wave 16.2 items, but the implementation showed they could not be cleanly deferred — the `play_counter` rollup columns are per-bucket by design (D6), and the share `channel` discriminator is a single non-null column on the `share_event` table. Building the log without them would have required a migration to add them in 16.2 anyway. - **Shape:** - **`PlayBucket` enum** (`DeepDrftModels.Enums`): `Partial` (< 30%), `Sampled` (30–80%), `Complete` (> 80%) — exhaustive, non-overlapping. D1 resolved. - **`PlayCounter` rollup columns** (`DeepDrftModels.Entities.PlayCounter`): `PartialCount`, `SampledCount`, `CompleteCount` (each `long`), `TotalPlays` (computed `long` sum). `BumpCounterAsync` in `EventRepository` switches on the bucket to increment the correct column in the same transaction as the event append. - **API-boundary bucket validation** (`EventController`): `Enum.IsDefined(payload.Bucket)` guard — an undefined bucket value returns `400 Bad Request` before the write reaches the repository. - **`ShareChannel` enum** (`DeepDrftModels.Enums`): `Link` / `Embed` on `ShareEvent.Channel`. `ShareTracker` passes the channel through from the `SharePopover` clipboard action; `EventController` validates it is a defined `ShareChannel` value. - **Deferred:** optional `share_count` rollup column on `play_counter` (per-track share count in the rollup table) — not built. Shares are not on the home-card hot path; per-target share reads are speculative wave 16.4 work. --- ## Home Hero Stats — Live data wiring (landed 2026-06-18) **Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`). - **What:** Replaced the hard-coded placeholder figures in the public home hero stat row (`NowPlayingStats`) with real SQL-backed aggregates. Resolves the "Real stat-row numbers" deferred item from Phase 0 §0.3. - **Why:** The stat row ("47+ / 2 / ∞") was intentionally hard-coded at Phase 0 with a TODO; the data model now has enough shape (releases, medium discriminator, track–release join) to serve real numbers in a single efficient query. - **Shape:** - **New SQL column:** `DurationSeconds` (`double?`, column `duration_seconds`) on `TrackEntity` and `TrackDto`. Populated at upload via the existing dual-database add flow (`TrackContentService` extracts duration from vault audio; `UnifiedTrackService` persists it to SQL). Migration `20260618155002_AddTrackDuration`. Configured in `TrackConfiguration`. - **New aggregate query:** `TrackRepository.GetHomeStatsAsync` → `HomeStatsDto` (new DTO in `DeepDrftModels/DTOs/`). Returns cut track count, per-`ReleaseType` cut release counts (zero-count types suppressed), mix release count, and total mix runtime seconds (null durations counted as 0; tracks under soft-deleted releases excluded). Surfaced via `ITrackService.GetHomeStats` on `TrackManager`. - **New API endpoints:** `GET api/stats/home` (`StatsController`, unauthenticated; returns `HomeStatsDto` bare) and `POST api/track/duration/backfill` (ApiKey-gated; one-time backfill of `DurationSeconds` for pre-existing rows from vault audio, delegated to `UnifiedTrackService.BackfillDurationsAsync`). - **New public proxy:** `StatsProxyController` in `DeepDrftPublic` mirrors `ReleaseProxyController`; forwards `GET api/stats/home` from the browser to DeepDrftAPI. - **New client surface:** `StatsClient` (`Clients/`, named `"DeepDrft.API"` client) + `IStatsDataService` / `StatsClientDataService` (`Services/`) registered scoped in `Startup.ConfigureDomainServices`. `RuntimeFormat` static helper (`Helpers/`) converts seconds to `hh:mm`. - **`NowPlayingStats.razor`:** now renders live data — Studio Cuts card (cut track count + zero-suppressed Single/EP/Album breakdown), Mixes card (`MixReleaseCount` "Sets" + `hh:mm` runtime), Plays card (static "XXX / Coming Soon" odometer placeholder). Uses `PersistentComponentState` to bridge the SSR prerender fetch across the WASM seam (only persists on a successful load). --- ## Phase 12 — About Page (public site editorial) (landed 2026-06-17) **Landed:** 2026-06-17 on dev. - **What:** A real About page for the public site (`/about`), built entirely in the **Home page's existing visual language** — no new look. Three movements — **the People**, **the Process**, **the Product** — with ethos / pathos / logos woven through the prose as registers, not labelled blocks. The strategic frame (Daniel): the site is *presentation and proof of effort* — evidence that real people are pushing the classic club sound forward; the About page is where that claim is made explicit. Built as `DeepDrftPublic.Client/Pages/About.razor` + scoped `About.razor.css`; registered in the nav index (`DeepDrftPublic.Client/Layout/Pages.cs`). Images served statically from `DeepDrftPublic/wwwroot/img/`; image slots and Khabran's bio degrade gracefully until final assets/copy land. - **Why:** This is its own phase, not a graft onto Phase 11: Phase 11 was structural (release-cardinal browse, queue, GUID handles), whereas this is a net-new **editorial** surface. The page reuses Home's section primitives wholesale (`.hero`, `.section-divider`, two-column `.section`, dark `.section-dark` feature band, `.section-split`, `.cta-banner`, `ParallaxImage` full-bleed bands) — no new visual language introduced; the only candidate new styling is two member-bio cards, assembled from existing type tokens. Full spec: `product-notes/about-page.md`. - **Shape:** `DeepDrftPublic.Client/Pages/About.razor` (new; `@page "/about"`; three-movement editorial page using Home section primitives); `DeepDrftPublic.Client/Pages/About.razor.css` (new; scoped styles — Home section primitives currently re-declared here rather than shared globally, a known follow-up); `DeepDrftPublic.Client/Layout/Pages.cs` (nav index registration added). Static images from `DeepDrftPublic/wwwroot/img/`. **Voice constraint (hard):** smart, serious, no AI-isms — underground Detroit/Midwest deep-club-house heritage carried to Charleston. All body prose remains DRAFT pending Daniel's approval — section headers and UI labels are set; any sentence/paragraph of site copy is a placeholder until Daniel passes it. **Open follow-ups:** (1) ~~Final photo files for the five image slots~~ Photo slots largely resolved: bio portraits (Khabran + Daniel) now wired as circular framed portraits with a bw→colour hover crossfade (`dd-khabran`, `dd-daniel`); Process hands-on-gear figure now uses bespoke `dd-mixer-2`; the Liner Notes redesign removed the separate full-bleed atmosphere and closing-band slots, so the duo hero (`dd-duo-2`) and the hands-on-gear inset are the only full-bleed image positions remaining and both carry bespoke assets. Home page "Our Origin" split swapped the retired `kp-shoulder` for `dd-pedals`. (2) ~~Khabran's bio text still open~~ Khabran's bio is now wired (three paragraphs; bio-body render updated to emit each paragraph as its own `

` — Daniel's single-paragraph bio is unaffected). (3) Optional promotion of the duplicated Home section primitives from `About.razor.css` to a shared global stylesheet. (4) ~~Whether CUTS/SESSIONS/MIXES are explained on the page~~ Resolved by the Liner Notes redesign: the triptych renders as a stacked editorial definition list (see Redesign addendum below). **Redesign — Liner Notes editorial treatment (2026-06-17):** The page was rebuilt from the Home section primitives approach into a distinct **"Liner Notes"** editorial layout. Structure and copy (three-movement People / Process / Product) are unchanged; the visual treatment is entirely new. Key elements: numbered left rail (oversized Bodoni 01/02/03 movement numerals + continuous vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking left into the margin, hand-authored SVG waveform movement dividers (a self-contained decorative motif, not the live `WaveformVisualizer` component). The CUTS/SESSIONS/MIXES triptych is now a stacked editorial definition list rather than Home's medium-card image grid. Active-movement highlight on the left rail is progressive enhancement via a new `DeepDrftPublic/Interop/about/about-rail.ts` IntersectionObserver interop (compiled output gitignored). Superseded Home section primitives were removed from `About.razor.css`; the global stylesheet was untouched. Design authority: `product-notes/about-page-distinction.md` (Direction 1). --- ## Phase 15 — Visualizer Controls Enhancements (landed 2026-06-17) **Landed:** 2026-06-17 on dev. - **What:** A presentation and interaction rework of the waveform visualizer control surface — the eight-RadialKnob panel (Phase 12) hosted by `WaveformVisualizerControlPopover`. Not a renderer change: the WebGL2 visualizer, the eight continuous dial values + their defaults, and the `Changed`-event bridge seam are unchanged. The phase reworks how the controls are reached and presented, adds two on/off toggles (lava, waveform), and gives the panel a deterministic, sectioned layout that encodes the visualizer's composition (lava field + waveform ribbon, optionally overlaid). Four tracks shipped as a single bundled PR (`15.A → {15.B, 15.C} → 15.D`): - **15.A — State booleans + bridge wiring.** Two new `WaveformVisualizerControlState` booleans: `LavaEnabled` and `WaveformEnabled` (both default `true`). `WaveformVisualizer.ts` gained a genuine per-subsystem draw-skip: when a subsystem is "off" it is not drawn, contributes no collisions, and incurs no render cost (not dimmed). The bridge pushes the new booleans on `Changed` alongside the eight existing dials. The per-subsystem draw-skip seam was built as part of this track (it did not exist prior). - **15.B — Screen-centered tinted-modal primitive + NowPlayingCard chrome.** `WaveformVisualizerControlPopover` changed from an anchored `MudPopover` to a screen-centered, tinted modal `MudOverlay` (`DarkBackground`, `Modal="true"`). The `AnchorOrigin`/`TransformOrigin` parameters were dropped — a centered modal has no anchor. Panel chrome follows the NowPlayingCard look: square corners, lighter-navy ground, thin light border. Chrome classes stay in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). Tint opacity resolves from a single `--deepdrft-modal-scrim-alpha` token. Knob-drag safety is preserved: `RadialKnob` mounts its own `position:fixed` capture div above the scrim while dragging, so releasing outside the panel does not close the modal. - **15.C — Deterministic three-row layout + toggles + scroll slider.** The flat eight-knob grid replaced by a three-row sectioned layout: **Row 1 (MODE, always visible):** two lamp toggles (lava / waveform) left-aligned + collisions knob (only when both subsystems on) + color knob pinned far-right. **Row 2 (LAVA, visible only when lava on):** "LAVA:" label + Gravity / Heat / FluidAmount / FluidViscosity knobs. **Row 3 (WAVE, visible only when waveform on):** "WAVE:" label + scroll/zoom `MudSlider` (bound to `ScrollSpeed` alone) + width knob pinned far-right. The lamp toggles use the `DDIcons.LavaLamp` / `DDIcons.LavaLampFilled` glyph (lit = on, unlit = off) and are green (`Color.Primary`) because they are interactive. - **15.D — Tooltips + light icon colour.** Each control received a playful, non-technical `MudTooltip`. Knob caption icons and section labels changed to light (`Color.Default` / CSS light token) per the resolved colour principle: green = interactive elements (toggles, knob arcs/pointers, scroll slider); light = static/decorative elements (section labels, caption icons). - **Why:** The eight-knob flat grid gave the user no signal about which knobs drive the lava vs. the waveform, and neither subsystem could be turned off independently. The new layout sections controls by subsystem, making "lava only" / "waveform only" first-class operating modes. The screen-centering solves the anchored-popover problem: `MudPopover` positions off its trigger's bounding rect — wrong for a control panel that should read as centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient, NowPlaying corner). - **Shape:** `DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor` — three-row layout replacing the flat eight-knob grid; two `ToggleLava`/`ToggleWaveform` handlers; conditional row visibility; `MudSlider` for scroll speed. `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor` — `MudPopover` replaced by `MudOverlay` (centered, `DarkBackground`, `Modal`); `AnchorOrigin`/`TransformOrigin` parameters removed. `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — two new boolean properties (`LavaEnabled`, `WaveformEnabled`) and matching `DefaultLavaEnabled`/`DefaultWaveformEnabled` consts (both `true`). `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — per-subsystem draw-skip seam (lava physics + blob uploads skipped when lava off; ribbon SDF + collision boundary dropped when waveform off). `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` — `--deepdrft-modal-scrim-alpha` token; `.waveform-visualizer-control-overlay` centering; `.waveform-visualizer-control-modal` panel chrome (square corners, lighter-navy, thin border); row/section layout classes (`wvc-row`, `wvc-row-mode`, `wvc-row-section`, `wvc-row-wave`, `wvc-section-label`, `wvc-toggle`, `wvc-slider`). Full design, layout contract, primitive rationale, tooltip copy, and acceptance: `product-notes/phase-15-visualizer-controls-enhancements.md`. **Post-landing fixes (2026-06-17):** Seven defects found during smoke-testing were remediated in a follow-up round on dev: (1) new `--deepdrft-panel-ground` CSS token so the blue slider reads against the panel background; (2) drag-scrollbar removed + body-scroll locked while the modal is open; (3) knob caption icons forced light so lamp toggles stay green; (4) WAVE-row slider vertically centered; (5) **site-wide `RadialKnob` pointer-capture fix** — drag no longer sticks when the cursor leaves the browser window, implemented via real `setPointerCapture` / `releasePointerCapture` (benefits every `RadialKnob` on the site, not just this panel); (6) modal scrim alpha softened (0.3 → 0.15); (7) modal overlay z-index raised above the header and player-dock footer. Fix #5 introduced a **new TypeScript interop module in `DeepDrftShared.Client`**: `Interop/knob/knob.ts` (exports `capturePointer`/`releasePointer`), compiled to `wwwroot/js/knob/knob.js` via `Microsoft.TypeScript.MSBuild`, lazy-imported by `RadialKnob.razor` as `_content/DeepDrftShared.Client/js/knob/knob.js` — following the existing `parallax.ts` precedent in the same RCL. **Polish round 2 (2026-06-17):** Five further UI changes from Daniel's second review: (1) panel ground darkened further (`--deepdrft-panel-ground` `#1e2028` → `#1a1c22`); (2) **WAVE-row scroll/zoom control reverted from `MudSlider` back to a `RadialKnob`** — Daniel's explicit call, reversing the §8 slider decision; the scroll control is now a knob like the other dials; (3) **waveform toggle given its own distinct icon** — new `DDIcons.Waveform`/`WaveformFilled` six-bar sound-wave glyph, so the waveform toggle and lava toggle each have a unique visual identity (lava toggle keeps the lamp); (4) **strong active-state styling on both toggles** — green-accent filled chip + ring when ON, dimmed when OFF, making subsystem state unmistakable at a glance; (5) `WaveformVisualizerControlPopover.razor` in-source comment refreshed to describe the `setPointerCapture` mechanism. --- ## Phase 14 — CMS Releases Consolidation (landed 2026-06-17) **Landed:** 2026-06-17 on dev. - **What:** Retired the CMS `/tracks` list view and consolidated all release browsing into a new standalone **`/releases`** page (`DeepDrftManager/Components/Pages/Tracks/Releases.razor`). The TRACKS|RELEASES `BrowseMode` toggle is gone. The `/releases` layout is: bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid. The unique per-track waveform-status columns (Profile / High-res, with per-row generate buttons) and the per-track info tooltip (EntryKey + OriginalFileName) now live in `CmsAlbumBrowser`'s expanded child-row track table; page-level bulk runs and per-row generates share a refresh bridge (`InvalidateWaveformStatusAsync` + `OnWaveformGenerated` EventCallback wired through each medium container). The `/catalogue` dashboard cards changed from Tracks / Releases / Genres to **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=` with the matching tab pre-selected. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; `/tracks/genres` was removed. Operational sub-routes (`/tracks/upload`, edit routes, `/tracks/mixes`, `/tracks/sessions`, etc.) stayed at `/tracks/*`. `ICmsTrackService.GetGenreSummariesAsync` removed (dead interface member). `GetTrackCountAsync` intentionally retained — planned for the public-site NowPlayingStats feature. - **Why:** The `/tracks` page mixed a list view and a releases browser behind a toggle (`BrowseMode`), and the waveform-status columns cluttered a per-track list that had no natural home once releases became the cardinal browse unit. Consolidating into a dedicated `/releases` page with a medium tab strip matches the release-medium mental model established in Phase 9 and makes waveform management a subordinate detail of the release's expanded track table rather than a top-level grid column. Retiring genre browse removes a dead-end CMS surface (genre is a filter, not a first-class browse dimension for the admin). - **Shape:** New: `DeepDrftManager/Components/Pages/Tracks/Releases.razor` (`@page "/releases"` + alias routes for `/tracks`, `/tracks/albums`, `/tracks/archive`). Deleted: `TrackList.razor`, `CmsTrackGrid.razor` (+ `.css`), `CmsGenreBrowser.razor` (+ `.css`), `Services/CmsTrackBrowserViewModel.cs` (+ its DI registration in `Program.cs`). Changed: `Index.razor` dashboard cards updated to CUTS / SESSIONS / MIXES deep-linking to `/releases?medium=`; `CmsAlbumBrowser` expanded child-row track table gains waveform-status columns + info tooltip + `OnWaveformGenerated` EventCallback; `ICmsTrackService` / `CmsTrackService` — `GetGenreSummariesAsync` removed. --- ## Phase 13 — CMS Public Landing (landed 2026-06-17) **Landed:** 2026-06-17 on dev. - **What:** Gave `DeepDrftManager` (the CMS) a true public face: an unauthenticated splash at `/` with DeepDrft branding and a single **Login** CTA; authenticated admins are redirected past it to the catalogue. Previously `/` was the `[Authorize]`-gated catalogue dashboard, so an anonymous hit fell straight through to the login form with no front door. Pattern borrowed from the `MainHomeLayout` / `Home.razor` idiom (dedicated public layout + `HierarchicalRoleAuthorizeView` redirect-the-authed-user), branded to the DeepDrft navy/green/off-white identity (`DeepDrftPalettes.Cms`). Additive — the admin experience is intact; only the catalogue's route moved. Routing decision: **Option A — splash owns `/`, catalogue moves to `/catalogue`** (Options B and C were weighed and rejected). New files: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, `CmsHomeLayout`) wraps its body in ``: `Authorized` → ``; `NotAuthorized` → hero (`img/cms-hero.png`) + Login CTA (returnUrl → `/catalogue`). `Components/Layout/CmsHomeLayout.razor` — lean public layout (`DeepDrftPalettes.Cms` theme, "Deep Drft — Admin" AppBar, centered narrow `MudContainer`, `MudPopoverProvider` only). `Components/RedirectToCatalogue.razor` — inline `NavigationManager.NavigateTo("/catalogue")` redirect, mirroring `RedirectToAccessDenied`. Changed: `Index.razor` route `@page "/"` → `@page "/catalogue"`; `CmsLayout.razor` "Back to site" home button `Href` and tooltip updated to `/catalogue` / "Catalogue". AppBar wording resolved: "Deep Drft — Admin" in the bar, "Deep Drft" as the hero title. - **Why:** An anonymous visitor hitting the CMS root landed directly on the AuthBlocks login form with no DeepDrft context, branding, or explanation. The splash provides a proper front door while keeping the admin surface fully intact. - **Shape:** `DeepDrftManager/Components/Pages/Home.razor` (new); `DeepDrftManager/Components/Layout/CmsHomeLayout.razor` (new); `DeepDrftManager/Components/RedirectToCatalogue.razor` (new); `DeepDrftManager/Components/Pages/Index.razor` (route changed to `/catalogue`); `DeepDrftManager/Components/Layout/CmsLayout.razor` (home-button href + tooltip updated). Hero asset: `DeepDrftManager/wwwroot/img/cms-hero.png` (Daniel-supplied; page compiles and renders without it). Full spec: `product-notes/cms-public-landing.md`. --- ## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (all tracks landed 2026-06-17) **Landed:** 2026-06-17 on dev. Six tracks (12.A, 12.B1, 12.B2, 12.E, 12.C, 12.D) plus a bridge live-track fix, all merged. - **What:** Took the landed Mix WebGL2 lava visualizer (Phase 10 reframe) and made it the one track-cardinal visualizer — serving Mix detail, all Release Detail pages, and the home-page NowPlaying card — rendering the waveform of whatever track is currently playing/selected. Two deliverables: (1) the generalized engine serving three hosting modes, (2) the NowPlayingHero rewire. Full design, extraction analysis, per-track model, Direction B compute, wave decomposition: `product-notes/phase-12-waveform-visualizer-generalization.md`. - **12.A — Rename to the abstraction.** `MixWaveformVisualizer` → `WaveformVisualizer`, `MixVisualizerControls` → `WaveformVisualizerControls`, `MixVisualizerControlState` → `WaveformVisualizerControlState`, `MixZoomMapping` → `WaveformZoomMapping`, `MixVisualizer.ts` → `WaveformVisualizer.ts`. Mechanical rename across the five C#/Razor files + TS module + import path + DI registration. No behavior change; Mix detail identical after. - **12.B1 — Generalize high-res compute to every track + backfill (Direction B).** `MixWaveformResolution` → `WaveformResolution`. Vault `mix-waveforms` → `track-waveforms` (`VaultConstants.TrackWaveforms`), keyed per-track by `EntryKey`. New `WaveformProfileService.ComputeAndStoreHighResAsync` is the shared compute seam — upload path, CMS generate action, and Mix trigger all funnel through it. `UnifiedTrackService.UploadAsync` now computes the high-res datum for every new track. CMS generate action generalized to any track; a re-runnable "backfill high-res" batch action added in the CMS `TrackList`. `WaveformStatusDto.HasHighRes` added alongside the existing `HasProfile`. Backfill is Daniel-gated (CMS batch action; fetch 404s gracefully for not-yet-backfilled tracks). - **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal endpoint `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + public proxy; `ITrackDataService.GetTrackWaveform`; bridge resolves the current track's `EntryKey` and re-fetches on track change. Client `GetMixWaveform` read path retired; API-side release waveform endpoint kept as a caller-less legacy delegate. Mix renders the same high-res lava via the track-cardinal fetch. - **12.E — Popover-hosted control panel.** `WaveformVisualizerControls` became the panel content; new `WaveformVisualizerControlPopover` pairs the lava-lamp icon with the panel as overlay content (`MudPopover`). Panel styled to the NowPlaying Hero look from `deepdrft-tokens.css` (no hardcoded hex). A `PanelChrome` flag scopes panel chrome to the popover mount. One popover placed by the lava-lamp icon on every host — full parity across Mix, Cut, Session, and NowPlaying card. - **Bridge live-track fix.** The visualizer now follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`), not the fixed host `TrackId`. - **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B).** New optional `Ambient` slot on `ReleaseDetailScaffold` (full-bleed layer behind content; absent slot = no regression). Cut mounts the ambient visualizer + the lava-lamp icon → popover. Session mounts the engine directly behind its hero (it doesn't compose the scaffold) + the popover. Mix swapped its inline controls bar for the lava-lamp icon → popover, keeping its own full-bleed mode-A mount. - **12.D — NowPlayingHero rewire (mode C).** `NowPlayingCard` replaced the 20 synthetic CSS bars with a contained `` driven by the live cascaded player, pointed at the current track. Added a `Fill` container-sizing mode (CSS-only, defaults off). Placed the lava-lamp icon → popover on the card for full parity. Visualizer runs at-rest on the home page even before playback (deliberate; perf tuning deferred). - **Why:** The landed Mix visualizer was structurally track-cardinal below the surface (bridge keyed on `TrackId`; renderer a pure function of a loudness datum + duration) but named `Mix*` throughout and restricted to Mix-only data. "Generalize" was a rename + per-track high-res compute extension, not a rebuild. Direction B (high-res for all media) was chosen over the cheaper 512-bucket-fallback Direction A to deliver uniform waveform quality. Controls moved from per-page inline knob bars to a single popover-hosted panel to achieve zero-cost placement on any host including the small NowPlaying card. - **Shape:** `DeepDrftPublic.Client/Controls/`: `WaveformVisualizer.razor` (+ `.razor.cs`, `.razor.css`) — renamed engine, added `[Parameter] bool Fill`; `WaveformVisualizerControls.razor` — renamed, now panel content with `PanelChrome` flag; `WaveformVisualizerControlPopover.razor` — new, lava-lamp icon + `MudPopover` wrapping the panel; `WaveformZoomMapping.cs` — renamed; `ReleaseDetailScaffold.razor` (+ `.razor.cs`) — new optional `Ambient` `RenderFragment` slot; `NowPlayingCard.razor` — synthetic bars replaced, `` + ``. `DeepDrftPublic.Client/Services/`: `WaveformVisualizerControlState.cs` — renamed. `DeepDrftPublic.Client/Pages/`: `CutDetail.razor` — mounts ambient visualizer + popover; `SessionDetail.razor` — mounts engine + popover directly; `MixDetail.razor` — swaps inline controls bar for popover. `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — renamed TS module. `DeepDrftContent/Processors/`: `WaveformResolution.cs` — renamed; `WaveformProfileService.cs` — `ComputeAndStoreHighResAsync` added, medium-neutral. `DeepDrftContent/Constants/VaultConstants.cs` — `TrackWaveforms = "track-waveforms"`. `DeepDrftAPI/Controllers/TrackController.cs` — `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + `POST api/track/{trackId}/waveform/high-res` (ApiKey, generalized generate); `WaveformStatusDto.HasHighRes` populated. `DeepDrftAPI/Services/UnifiedTrackService.cs` — `UploadAsync` now calls `ComputeAndStoreHighResAsync` for every new track. `DeepDrftPublic/Controllers/TrackProxyController.cs` — proxy for the new high-res endpoint. --- ## Phase 10 — Mix Visualizer Reframe: Waves R1–R4 (Lava tuning + eight-knob controls) **Landed:** 2026-06-17 on dev. - **What:** A major reframe of the Mix visualizer's effects, controls, and color model, built on the landed WebGL2 Phase 10 renderer infrastructure. Four waves: - **Wave R1** — removed the static noise/frost texture (Daniel: "makes the screen look dirty"); implemented dynamic footer-height clip so the lava stops cleanly above the player bar; redrawn `DDIcons.LavaLamp` to the classic 1970s silhouette (wide truncated-cone base, bulbous→roundedly-pointed teardrop glass body, small cone cap — navy fluid + moss blobs, body `currentColor`). - **Wave R2** — CPU-side per-frame physics step (~16–32 Lagrangian wax blobs: position/velocity/temperature/radius), uploaded as uniforms each frame; `smin` SDF metaball render producing a **flat, coalescing fluid** (not blobs with radial hotspots); energy-coupled dynamics (high heat → many small turbulent bubbles; low heat → fewer large calm masses); 2D elastic collision on both blob↔waveform and blob↔blob pairs; collision strength knob sweeping from genuine soft mush to a high-elasticity upward-and-outward throw; waveform collision always on regardless of heat. Loudness profile smoothed with a **~15 ms envelope-follower at preprocessing only** (`RmsLoudnessAlgorithm.cs`); there is no decode-time smoothing (`smoothDatum` was removed). Existing vault mixes gain the smoothing only after server-side reprocessing — they do not benefit automatically. Ribbon rendered with smootherstep sinusoid reconstruction. - **Wave R3** — replaced HSL `mixHsl`/`vivify` color with OKLab interpolation (structural fix for the cyan excursion artifact); three combined gradient motions: (1) A/B anchor rotation among three theme colors at the gradient-rotation-speed rate; (2) per-segment sinusoidal variation keyed to mix-time so colors travel with the segment as it scrolls; (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). Static noise texture removed. One source of truth (`DeepDrftPalettes`), no hardcoded hexes. - **Wave R4** — `MixVisualizerControlState` widened from four properties to **eight**: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, **`FluidAmount`** (replaces the former `BlobDensity`), **`FluidViscosity`** (new — cohesion / coalescence control, the second half of the bubbles split), `CollisionStrength`, `WaveformWidth`. `MixVisualizerControls` now renders **eight** `RadialKnob`s; the `Visible` parameter `@if`-gates the knob band while the container holds a reserved min-height so content below never pops when the lamp toggles. Scroll-speed knob range tuned to 60–110% band; gravity 0–75%; heat +20% at top; width default 50% / range 10–95%. The scaffold's `TopRowCenter` slot (added in the prior reframe) carries the controls in-flow between the back link and lava-lamp toggle. - **Why:** Daniel tested the Phase 10 effects end-to-end and rejected the visual result: lava read as "giant disconnected circles," colors drifted to cyan (HSL arc artifact), waveform and lava read as two unrelated layers. The diagnosis was that these were structural failures of the prior model (too few scripted blobs with no physics; HSL hue-arc through cyan), not tuning misses. The reframe replaced the model with CPU-physics wax blobs + OKLab gradients, fixing both root causes. - **Shape:** `DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs` — ~15 ms envelope-follower smoothing added at preprocessing (server-side only; no decode-time smoothing). `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — smootherstep sinusoid ribbon reconstruction; wax-blob physics loop; OKLab gradient; footer-clip; noise texture removed. `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — widened to eight properties (`FluidAmount` + `FluidViscosity` replace `BlobDensity`; `WaveformWidth` range/default updated). `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor` — eight `RadialKnob`s, `Visible` parameter gates knob band via `@if` while container holds reserved height. `DeepDrftShared.Client/Common/DDIcons.cs` — `LavaLamp` glyph redrawn. Full design, acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`. --- ## Phase 11 — Public Site Enhancements (complete — all tracks 11.A–11.H landed 2026-06-16) ### 11.H — release `EntryKey` identifiers (terminal public-site wave) **Landed:** 2026-06-16 on dev. - **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index. - **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11. - **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied. --- ### 11.D — Archive filters in the URL **Landed:** 2026-06-16 on dev. - **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase. - **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines). - **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`. --- ### 11.E — release-level Share **Landed:** 2026-06-16 on dev. - **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched. - **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic. - **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to ``. --- ### 11.C — retire track-cardinal stack + normalize release cards **Landed:** 2026-06-16 on dev. - **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet. - **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places. - **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func? HrefResolver { get; set; }` and `[Parameter] public Func? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`. --- ### 11.B — `ReleaseRoutes` resolver + repoint **Landed:** 2026-06-16 on dev. - **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C. - **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting. - **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id. --- ### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F) **Landed:** 2026-06-16 on dev. - **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`. - **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only. - **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`. --- ### 11.A — `/cuts/{id}` album-detail page **Landed:** 2026-06-16 on dev. - **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema. - **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`. - **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`). --- ### 11.F — play-queue `IQueueService` **Landed:** 2026-06-16 on dev. - **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`. - **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar. - **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable, int)` loads an ordered track list and starts playback. --- ### 11.G — release Description schema slice **Landed:** 2026-06-16 on dev. - **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated). - **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine). - **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied. --- ## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only) **Landed:** 2026-06-16 on dev. - **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `

` (no `MudPaper`). - **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack. - **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by ``; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css` — `mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor` — `bool ShowHeader = true` gate added around the default header region. Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`. --- ## CMS Grid Refinements ### `CmsAlbumBrowser` special-action column promotion **Landed:** 2026-06-15 on branch `cms-special-action-columns`. Follow-on refinement of 8.C: the `RenderFragment? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell. - **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList SpecialColumns { get; set; }` (defaulting to `Array.Empty()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `` per declared column (between the Tracks header and the Actions header) and one `` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`). - **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column. - **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file. **Completion note:** `CmsAlbumBrowser.razor` — `RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment Cell)`. `CmsMixBrowser.razor` — `RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks). --- ## Phase 9 — Release Medium Types ### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality **Landed:** 2026-06-13 on dev. The single-track-per-release rule for Session/Mix is enforced only in the CMS form layer (the `BatchUpload`/`BatchEdit` master-list collapse, §9.6.B). This wave makes per-medium cardinality a real domain invariant at the upload-service boundary. Full design — the generalised rule, the enforcement-layer trade-offs, the orphan-avoidance reordering, the relationship to the existing rules, and the back-compat reality — lives in `product-notes/phase-9-medium-cardinality-invariant.md`. - **What:** Promote per-medium track-count from a form convention to a domain invariant enforced at the upload-service boundary. Declare each medium's allowed cardinality as data — `Cut → 1..N`, `Session → 1..1`, `Mix → 1..1` — in a single `ReleaseMedium`-keyed lookup (`MediumRules`, in `DeepDrftModels`), extensible by one entry per future medium. `UnifiedTrackService.UploadAsync` reads the resolved release's medium + live track count and **rejects** a track-add that would exceed the medium's `Max` (only the find path — a freshly created release is always within range). The existing `CountLiveTracksByRelease` (already on `ITrackService`, backs the delete cascade) supplies the count; no new counting primitive. - **Why:** Daniel ruled single-track-per-Session/Mix a *hard constraint* (§9.5/§9.6, resolved). Today it is form-deep only — the upload endpoint and any scripted ApiKey caller bypass it, and the first-upload-authoritative write path adds a second track to an existing non-Cut release with no check. The data model itself does not forbid what the product forbids. Hardening it at the service layer makes every domain writer pass the rule, closes the gap, and — by declaring cardinality as one shared rule both the form and the service read — guarantees the UI and the domain cannot drift. - **Shape:** - **The rule as data.** `MediumRules.CardinalityOf(medium)` returns a `(Min, Max)` value type; no three-arm `switch` in any service. The same lookup the upload service enforces is the one the CMS form collapse reads (refactor `OnMediumChanged` from its hardcoded `medium is Session or Mix` to `MediumRules.CardinalityOf(medium).IsSingleTrack`) — one source, two consumers (form shapes the UI, service enforces the limit), so they cannot diverge. This is a consume-the-new-rule refactor of §9.6.B's landed collapse, **not** a re-litigation of it. - **Enforcement in the orchestrator, not `TrackManager`.** The check lives in `UnifiedTrackService` (the true boundary for a track-add-to-a-release operation), not the lower-level SQL `Create`. Express the guard generally — `if (liveCount + 1) > cardinality.Max` — so a future bounded-but-not-single medium is covered by the same line. - **Reorder to avoid orphaning the vault write.** Today `UploadAsync` writes the vault *before* resolving the release. A rejection at that point orphans the audio. Move the cardinality pre-check **before** `AddTrackAsync`: peek the release by `(album, artist)` (a read via the existing `GetReleaseByTitleAndArtistAsync`, not a create), read its medium + count, reject early — then vault-write only the accepted upload. This reordering is part of the wave, not an afterthought. - **Violation behaviour.** Return a NetBlocks `ResultContainer` failure with a clear message ("A {medium} release holds a single track; '{title}' already has one"). The controller surfaces it as a `409 Conflict` (honest — well-formed request, rule violation) if cheap, `400` otherwise. The CMS already bubbles upload-failure messages inline; no bespoke UI — the common case never reaches the API because the form collapse stops it first, so this is the backstop for the paths the form does not cover. - **Leave `ReleaseType`-applicability alone.** Do **not** merge the cardinality rule with the `ReleaseType`-only-for-Cut invariant — they are different kinds of rule (count constraint vs. field relevance). They may co-locate as separate named members of `MediumRules`, but no generic "medium invariant engine." Only cardinality is new this wave. - **Tests.** Extend `MediumWritePathTests` (the §9.5 EF in-memory fixture): Session/Mix reject a second track-add; Cut accepts the Nth; first track on a new Session/Mix succeeds; `MediumRules.CardinalityOf` returns the declared ranges. - **Acceptance criteria:** A second track-add to an existing Session or Mix release is rejected at `POST api/track/upload` with a clear failure message and no vault orphan; a Cut release accepts many tracks unchanged; the first track on any medium succeeds; the CMS form collapse and the service enforcement both read `MediumRules` (no duplicated cardinality logic); the existing `ReleaseType`-only-for-Cut enforcement is untouched. - **Back-compat (verified):** No violating data exists — Phase 9 is unmerged, every release migrated to `Cut` (many-track), zero multi-track Session/Mix releases exist. A DB backstop (if chosen, see open question) goes on clean with no data-cleanup migration; the service check has nothing to reconcile. Note honestly: **no** DB-level cardinality or medium constraint exists today (`ReleaseConfiguration` carries only the `(title, artist)` unique index and the `is_deleted` index) — closing that absence is the wave. - **Open question (Daniel — philosophy call, not pre-empted):** Enforce the cardinality invariant in the **`UnifiedTrackService` domain layer only** (recommended), or *also* add a **Postgres constraint-trigger DB backstop** so a future writer that bypasses the service cannot violate it? - **Service-only (recommended).** Consistent with the phase's own documented stance — the `ReleaseType`-only-for-Cut invariant chose service enforcement over `HasCheckConstraint` *by choice, not necessity* (`phase-9-release-medium-types.md` §1); cardinality is the same advisory-vs-storage shape and choosing the DB here would split the phase's philosophy. `UnifiedTrackService` is the *only* track-add path today — the "non-CMS caller" still goes through it (`POST api/track/upload`). The bypass a DB backstop defends against (a writer skipping the service entirely) does not exist in the codebase. And the migration is clean either way, so the backstop is free to add *later* if a second writer ever appears. - **DB backstop (defer).** A partial unique index cannot express this directly (the medium lives on the `release` table, not `track`; Postgres partial predicates can't cross tables). The expressible form is a hand-written PL/pgSQL constraint-trigger EF does not model — a standing maintenance surface. Defensible only if Daniel wants storage-layer immutability over service-layer truth. - **Recommendation: service-only (C3), defer the DB backstop (C2) as a free-to-add-later option.** This is a decision about where the system's structural truth lives — the service layer vs. the storage layer — not an implementation detail. It is Daniel's to make. Two minor sub-questions ride along (`409` vs `400` status; `MediumRules` in `DeepDrftModels`) — both have clear recommendations and should not block. **Completion note:** **Decision: C3 — service-layer enforcement only. NO DB backstop, NO migration, NO trigger** was implemented. `MediumRules` (new, in `DeepDrftModels/Enums/`): a `MediumCardinality` record struct (`Allows`, `IsSingleTrack`) + a `CardinalityOf` lookup declaring `Cut = 1..∞`, `Session = 1..1`, `Mix = 1..1` — one declaration, read by both the service and the form. Enforcement in `UnifiedTrackService.UploadAsync`: a general `(trackCount + 1) > cardinality.Max` guard on the find path (existing release), reordered to run as a **read-only peek BEFORE the vault write** so a rejected over-limit upload never orphans audio. The peek uses a new read-only `GetReleaseByTitleAndArtist` on `ITrackService` (returns medium + live count, no create). Violation → NetBlocks failure result, mapped by `TrackController` to **HTTP 409 Conflict** (via a sentinel message marker mirroring the existing `TrackNotFoundMessage`/`NotFound()` pattern). The CMS form collapse predicates (`BatchUpload.OnMediumChanged`, `BatchEdit.OnMediumChanged` + load-path) were refactored to read `MediumRules.CardinalityOf(medium).IsSingleTrack` — form and service now share one source; behaviour unchanged. `ReleaseType`-only-for-Cut enforcement was left untouched. Nine new tests in `MediumWritePathTests`. Accepted residual items (per the C3 stance): a narrow TOCTOU window between peek and create (single-writer stance accepts it), and an integration-test gap on the no-orphan ordering (no vault seam in the EF in-memory fixture). All acceptance criteria met; Wave 7 hardens per-medium cardinality from a UI convention into a real domain invariant. --- ### 9.8 Wave 8 — Remediation (fully landed: 8.A–8.J + 8.L, 8.M, 8.K) **Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L); 8.M on 2026-06-14; 8.K on 2026-06-14. Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete. **8.A — Release Archive as medium tabs, not cards** - **What:** Retire the three navigate-away medium cards (`ReleaseArchiveBrowser`); replace with an in-page `MudTabs` strip (`ALL` + one tab per medium) that swaps the grid below in place. Retire the redundant top-level **Releases** toggle item (the `ALL` tab subsumes it). - **Why:** The card-grid landing required navigation away to reach per-medium grids. Daniel's testing pass identified the correct shape as an in-page tab strip — medium selection without leaving the page. - **Shape:** `TrackList.razor` renders a `MudTabs` strip when `VM.Mode == BrowseMode.Albums`: the `ALL` panel hosts `CmsAllReleasesGrid` (the 8.B component); per-medium tabs are enum-driven via `Enum.GetValues()` with a `MediumTabLabels` dictionary for display text and a `MediumGrid(medium)` render-fragment `switch` for content (Cut → `CmsCutBrowser`, Session → `CmsSessionBrowser Embedded="true"`, Mix → `CmsMixBrowser Embedded="true"`, fallback `_ =>`). The `/tracks/archive` deep-link route resolves to the Releases/Albums mode via URL inspection in `OnInitializedAsync`. `ReleaseArchiveBrowser.razor` and its `.razor.css` were deleted. `BrowseMode.Archive` was removed from `CmsTrackBrowserViewModel.cs`. New `CmsCutBrowser.razor` (a Cut-filtered grid) derives from `CmsMediumBrowserBase`, `Medium => ReleaseMedium.Cut`. `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained an `[Parameter] public bool Embedded { get; set; }` on the subclass (not on `CmsMediumBrowserBase`, which is untouched); when `true`, standalone page chrome (container, title, "Back to Release Archive" button) is suppressed and only the grid renders; standalone routes keep the chrome. Their §9.5.E per-row Edit and hero/waveform row actions are preserved in both contexts. `/tracks/sessions`, `/tracks/mixes`, `/tracks/archive` remain reachable by direct URL. No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference). Known internally-consistent characteristic: CUTS/SESSIONS/MIXES tabs use the thin `CmsMediumTable` grid (cover/title/artist/edit) while ALL uses the richer `CmsAllReleasesGrid` (expand-tracks/delete/Type-chip); per-medium grid richness deferred to 8.C. **Completion note:** `TrackList.razor` replaced its former three-way toggle (Tracks / Releases / Release Archive) with a two-item toggle (Tracks / Releases); the Releases arm hosts a `MudTabs` strip with `ALL` (→ `CmsAllReleasesGrid`) and enum-driven medium tabs rendered via `MediumTabLabels` + `MediumGrid` render-fragment switch. `ReleaseArchiveBrowser.razor` and `ReleaseArchiveBrowser.razor.css` deleted. `BrowseMode.Archive` removed from `CmsTrackBrowserViewModel.cs`. New file `CmsCutBrowser.razor` (Cut-filtered, derives from `CmsMediumBrowserBase`, no standalone page route). `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained `[Parameter] public bool Embedded { get; set; }` on the subclass; base class untouched. `/tracks/archive` deep-link resolves to Albums mode. All gate acceptance criteria met; 8.C and 8.E layer onto this foundation. --- **8.D — Type column chip reads "Session" / "DJ Mix" for non-Cuts** - **What:** The cross-medium releases grid's Type column must not show a Cut-only `ReleaseType` chip (Single/EP/Album) for Session/Mix rows. For non-Cut media the chip reads the medium name — **"Session"** or **"DJ Mix"**. - **Why:** The CMS Release Archive grid and the `ALL`-tab grid show all releases together. When a Session or Mix row renders a Cut-only `ReleaseType` value, the UI contradicts the medium taxonomy — a Session row should read "Session," not "Single/EP/Album." - **Shape:** The Type cell was rendering `@context.Release.ReleaseType` unconditionally. Per Phase 9 read-model design, `ReleaseDto.ReleaseType` is nullable and nulled for non-Cut media at the mapping point. The cell becomes medium-aware: when `Medium == Cut`, show `ReleaseType`; otherwise show the medium's display name from a lookup (no hardcoded switch — a future medium's label comes free from the enum + lookup entry). - **Acceptance criteria:** Cut row's Type chip shows Single/EP/Album; Session row shows "Session"; Mix row shows "DJ Mix"; no row shows a Cut-only `ReleaseType` for a non-Cut medium. **Completion note:** The Type cell in `CmsAlbumBrowser.razor` was refactored to a single ternary: when `Medium == Cut`, renders `ReleaseType?.ToString() ?? "—"` (reusing the existing em-dash empty-cell idiom used by Genre and Release-Date cells); otherwise renders from `private static readonly IReadOnlyDictionary MediumTypeLabels` with entries `[ReleaseMedium.Session] = "Session"` and `[ReleaseMedium.Mix] = "DJ Mix"`. Dictionary name is **MediumTypeLabels**. A `@using DeepDrftModels.Enums` was added. Future non-Cut media require exactly one new dictionary entry — no markup change. Acceptance criteria met; Type column now correctly shows "Session" / "DJ Mix" for non-Cut rows. --- **8.B — `ALL` tab: all-releases grid with edit** - **What:** The left-most `ALL` tab shows the current cross-medium releases grid (every release, all media) with working edit buttons — the surface the retired **Releases** toggle used to show. - **Why:** The CMS Release Archive needed a unified view of all releases as a foundation for the tab-strip redesign (8.A). The grid already existed in `CmsAlbumBrowser`; 8.B makes it the `ALL` tab's content. - **Shape:** `CmsAlbumBrowser` displays the cross-medium releases grid with sort, delete (cascade + orphaned-release cleanup), expand-tracks, and per-row edit, all unchanged. The grid self-loads via `ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`, with an optional `[Parameter] public EventCallback OnReleasesChanged` for host cache invalidation (set in `TrackList.razor` for genre-cache sync). A single `ReloadAsync` path serves both initial load and post-delete refresh. **Completion note:** `CmsAllReleasesGrid.razor` (new, in `DeepDrftManager/Components/Pages/Tracks/`) wraps `CmsAlbumBrowser` as a self-loading component. Component owns its data load (`ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`), renders `CmsAlbumBrowser` internally, and refreshes after delete via `ReloadAsync()`. `OnReleasesChanged` callback parameter (optional, safe no-op when unset) lets a host invalidate sibling caches on mutation — `TrackList.razor` `BrowseMode.Albums` now renders `CmsAllReleasesGrid` and passes `OnReleasesChanged` so the genre cache still invalidates on release delete. `CmsTrackBrowserViewModel.cs` was trimmed: the now-redundant album load/cache (`Albums`/`AlbumsLoading`) was removed; `Invalidate()` narrowed to genre-only. `CmsAlbumBrowser` unchanged — sort, delete cascade, expand-tracks, per-row edit, Type chip (per 8.D) all preserved. No `@rendermode` override, no constructor growth, no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit/no DeepDrftManager reference; the underlying `GetReleasesAsync` data path is covered by existing tests). Files: `CmsAllReleasesGrid.razor` (new), `TrackList.razor`, `CmsAlbumBrowser.razor`, `CmsTrackBrowserViewModel.cs`. Acceptance criteria met; `ALL` tab grid with edit now live as an embeddable component, clearing the foundation for 8.A tab strip. --- **8.F — Session hero image in the upload form (retire the two-step)** - **What:** Compose the hero-image field into the Session upload form so a Session is authored in one pass; remove the "set it later from the browser" alert. Hero is **optional but warns if missing** (no hard gate). - **Why:** Sessions need their signature hero image. Requiring a post-upload trip to the Session browser is a friction point in the authoring flow. Embedding the hero upload in the creation form (mirroring the deferred cover-art `` UX) lets an admin author a complete Session in one submission. - **Shape:** `SessionFields.razor` renders a deferred hero-image `` (mirroring the cover-art deferred-upload UX), but **only `@if (AllowHeroUpload)`** — a new bool parameter. `AllowHeroUpload` is threaded `BatchUpload → AlbumHeaderFields → MediumFields → SessionFields` (same chain as the `HeroImageFile`/`HeroImageFileChanged` pair). It defaults `false`; only `BatchUpload` passes it `true`. On the edit forms (`BatchEdit`, `TrackEdit`, `TrackNew`) it stays false, so they show a `Severity.Info` guidance alert pointing to the Sessions browser per-row replace — no dead control. On submit, `BatchUpload` creates the release via the existing upload path, then POSTs the held hero file to the existing resource-addressed `POST api/release/{id}/session/hero-image` using `result.Value.ReleaseId`. Hero is optional with a non-blocking warn-then-proceed gate: a first Session submit with no hero shows a `Severity.Warning` message (`_warningMessage`) and primes acknowledgment; a later submit proceeds. The null-`ReleaseId` edge logs + Snackbars instead of dropping the file silently. - **Acceptance criteria:** Session upload form shows a hero-image `` alongside the cover art; hero upload optional (warning-then-proceed gate); edit forms show guidance alert instead of the hero field; per-row hero upload in `CmsSessionBrowser` unchanged; no sessions uploaded without hero field available. **Completion note:** `SessionFields.razor` gained `[Parameter] public bool AllowHeroUpload { get; set; }` and wraps hero-image `` in `@if (AllowHeroUpload)`. Hero image input shows only in upload form, suppressed in edit forms with guidance alert (`Severity.Info` routing to Sessions browser) visible instead. `AllowHeroUpload` parameter threaded through `MediumFields.razor → AlbumHeaderFields.razor → BatchUpload.razor` (set `true` only in `BatchUpload`; defaults `false`). `BatchUpload.razor` holds hero file in a field (`private IBrowserFile? _heroImageFile`) assigned by `SessionFields`'s `HeroImageFileChanged` callback, then POSTs held file to `POST api/release/{id}/session/hero-image` after successful release creation using `result.Value.ReleaseId`. Hero optional with non-blocking gate: `Severity.Warning` on first submit without hero, primes boolean; second submit proceeds (warning dismissed). Null `ReleaseId` edge case logs error + Snackbar instead of silently dropping file. Per-row hero upload in `CmsSessionBrowser` untouched (remains the replace/correct path). Files: `SessionFields.razor`, `MediumFields.razor`, `AlbumHeaderFields.razor`, `BatchUpload.razor`. Acceptance criteria met; hero image now composable in upload form with optional-but-warn semantics. --- **8.G — "Album Name" → "Release Name" label** - **What:** The `AlbumHeaderFields` form's first-field label reads **"Release Name"**, not "Album Name." - **Why:** The field now covers Cuts, Sessions, and Mixes — not just albums. "Release Name" is the accurate noun. - **Shape:** Rename `Label="Album Name"` → `Label="Release Name"` and the `RequiredError` string in `AlbumHeaderFields.razor`. Check placeholder/help text for consistency. - **Acceptance criteria:** The first field of the release header form reads "Release Name"; the required-validation message references "Release Name." **Completion note:** `AlbumHeaderFields.razor` `Label` and `RequiredError` changed "Album Name" → "Release Name". Matching validation message strings in `BatchEdit.razor` and `BatchUpload.razor` were updated to "Release Name is required" for consistency. Three files total; trivial rename, acceptance criteria met immediately. --- **8.J — ARCHIVE popover click does not close (bug)** - **What:** Clicking a popover child leaves the pure-CSS hover dropdown stuck open on SPA navigation. The desktop ARCHIVE menu (a hover-triggered `.dd-nav-dropdown`) has no JS dismissal — it hides only when cursor leaves or focus moves out. After enhanced SPA nav (Blazor keeps the DOM), the cursor often remains over the parent, so the dropdown stays visible. - **Why:** Dead affordance. An admin clicks "Sessions" in the dropdown, the nav updates in-place, and the dropdown stays floating over the new content, blocking clicks. Dismissal must be explicit (JS-based, not CSS-only). - **Shape:** Detect SPA navigation and trigger a dismissal handler. The existing `DeepDrftMenu.razor` / `DeepDrftMenu.razor.css` structure carries `.dd-nav-dropdown` with `:hover` and `:focus-within` CSS triggers. A JS `DismissDropdown()` function or a Blazor `@onmouseleave` handler on the parent can close the dropdown imperatively after nav. Coordinate with 8.I: if 8.I flattens the nav and removes the popover entirely on desktop (the three media become inline appbar items), the dismissal logic only survives on breakpoints/sub-menus where a popover remains. Fix applies where the popover still exists. - **Acceptance criteria:** Clicking a popover child (e.g. "Sessions") closes the dropdown; no dropdown floats after SPA navigation. Desktop and mobile both dismiss correctly. **Completion note:** `DeepDrftMenu.razor.css` updated with a new `.dd-nav-item-collapsed` rule (scoped `.dd-nav-item-parent.dd-nav-item-collapsed .dd-nav-dropdown`) using `!important` to override both the `:hover` and `:focus-within` show rules. Razor state: collapse tracked in `private readonly HashSet _collapsedDropdowns = []` keyed by `navPage.Route`; parent `
  • ` gets the class via `_collapsedDropdowns.Contains(navPage.Route)`. Child link's `@onclick` calls `CollapseDropdown(navPage.Route)` (adds route to set); parent `
  • `'s `@onmouseleave` AND `@onfocusout` both call `ResetDropdown(navPage.Route)` (removes it). Per-parent keying enables multiple independent dropdowns; `@onfocusout` reset lets keyboard users re-enable dropdown without mouse pass. Mirrors existing `CloseMobileMenu` pattern. The dropdown no longer floats after SPA navigation; acceptance criteria met. --- **8.L — Consolidate release name + track name for single-track releases** - **What:** For single-track media (Session and Mix), the UI presents **a single name** (Release Name). The track name is **derived from it automatically** on save and kept synced — the admin never enters or sees a separate "Track Name" field. Cuts (multi-track) remain unaffected (separate release and per-track names). This is a consolidation: today these forms surface *two* name inputs for media with only one logical name. - **Why:** A Session or Mix is a single work with one name. Surfacing a separate "Track Name" invites divergence (release "Lowcountry Live #3" whose track is "untitled-master-final") and a confusing authoring experience. The name consolidation removes that redundancy. - **Shape:** On **create** (`BatchUpload`, single-track medium): the form presents one name field (Release Name, via 8.G rename); no separate Track Name input. On save, `_tracks[0].TrackName` is set equal to the Release Name. On **edit** (`BatchEdit`, single-track medium): the form presents one name field (Release Name); the per-row Track Name editor is suppressed (via a flag passed to `BatchTrackDetail`). On save, the track's `TrackName` is set equal to the (possibly edited) Release Name — they stay synced. Switching the medium selector mid-form re-drives which name fields are visible (one name for Session/Mix; release + per-track names for Cut) without losing entered data. - **Acceptance criteria:** Single-track (Session/Mix) **create** path shows one name field (Release Name) with no separate Track Name input; on save, `TrackName == ReleaseName`. Single-track **edit** path shows one name field (Release Name); switching to Cut shows both; the form does not lose entered data on selector change. Track name stays synced with release name on edit (changing Release Name updates the track name). Cuts (multi-track) unaffected — Release Name and per-track Track Names are distinct. Legacy `TrackNew`/`TrackEdit` forms are **out of 8.L scope** (their retirement is 8.M). No public-site changes needed (public detail/gallery views already key off release title only). **Completion note:** `BatchTrackDetail.razor` gained `[Parameter] public bool ShowTrackName { get; set; } = true;` and wraps Track Name `` in `@if (ShowTrackName)`. `BatchUpload.razor` removes Track Name input on single-track branch, sets `_tracks[0].TrackName = _albumName` in `SubmitAsync` (after non-empty `_albumName` validation, before upload loop). `BatchEdit.razor` passes `ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"` to `BatchTrackDetail` and syncs `_tracks[0].TrackName = _albumName` in `SaveAsync`. The "is single-track" decision is driven by shared `MediumRules.CardinalityOf(_medium).IsSingleTrack` declaration (same one used by upload service and §9.7 invariant) — not a hardcoded Session/Mix check. Default `true` keeps Cut path and BatchUpload's Cut branch (passing no `ShowTrackName`) showing the field. No `MudForm`/`EditForm` wrapper exists, so hiding the field has no validation-deadlock effect. Single-track forms now present one name, consolidating two-field redundancy; Cuts unaffected with Release Name and per-track names distinct. Acceptance criteria met; Wave 8 track 8.L consolidates form UX to match single-track-per-medium design intent. --- **8.C — Per-medium grids gain working edit affordances (full parity with ALL tab)** - **What:** Cut / Session / Mix tab grids gain full parity with the ALL tab: expand-tracks, delete, Type chip, and per-row Edit action — the same rich `CmsAlbumBrowser` grid the ALL tab uses, filtered to each tab's single medium. - **Why:** The initial 8.A landing acknowledged that the per-medium tabs used the thin `CmsMediumTable` (cover/title/artist/edit) while ALL used the richer `CmsAlbumBrowser`; 8.C was the deferred parity track. Per-medium grids differing from the ALL grid in affordances was confusing and inconsistent. - **Shape:** Daniel decided option (b) — full parity. Each per-medium browser (`CmsCutBrowser`, `CmsSessionBrowser`, `CmsMixBrowser`) now renders `CmsAlbumBrowser` filtered to its single medium. `CmsAlbumBrowser.razor` gained one optional `[Parameter] public RenderFragment? RowActions` slot, rendered in the Actions cell before the shared edit/delete buttons; the ALL tab leaves it unset and is unchanged. `CmsMediumBrowserBase.cs` was refactored: it now feeds the rich grid a medium-filtered `Releases` projection (`IReadOnlyList`) alongside `ReloadAsync` (wired to the grid's post-delete `OnReleasesChanged`) and a `RowFor(release)` lookup (`_rowsById` dictionary keyed by `release.Id`) for per-medium action-state recovery by the `RowActions` fragment. Session hero and Mix waveform row actions are preserved via each browser's `RowActions` content. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` were deleted (now orphaned). No `TrackList.razor` change (the `MediumGrid` switch renders the same component identifiers). No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (no bUnit harness; medium-filter data path covered by `ReleaseBrowseQueryTests`). Files modified: `CmsAlbumBrowser.razor`, `CmsMediumBrowserBase.cs`, `CmsCutBrowser.razor`, `CmsSessionBrowser.razor`, `CmsMixBrowser.razor`, `CmsSessionBrowser.razor.css`; deleted: `CmsMediumTable.razor`, `CmsMediumTable.razor.css`. **Completion note:** `CmsAlbumBrowser.razor` gained `[Parameter] public RenderFragment? RowActions { get; set; }` rendered in the Actions cell (before edit/delete) via `@RowActions?.Invoke(context.Release)`; the ALL tab's `CmsAllReleasesGrid` wrapper passes nothing, leaving ALL unchanged. `CmsMediumBrowserBase` (generic, abstract) was rewritten: it now loads a medium-filtered release list via `ICmsReleaseService.GetPagedAsync`, projects to a bare `IReadOnlyList Releases` for the rich grid, maintains `_rowsById` for action-state recovery via `RowFor(release)`, and exposes `ReloadAsync()` wired to the grid's `OnReleasesChanged`. `CmsCutBrowser`, `CmsSessionBrowser`, and `CmsMixBrowser` were updated to render `CmsAlbumBrowser` (instead of the now-deleted `CmsMediumTable`) with their medium-specific `RowActions` fragment. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` deleted. Per-medium tabs now render the same expand-tracks / delete / Type-chip / edit grid as the ALL tab, single-sourced. Acceptance criteria met; Wave 8 track 8.C brings per-medium grids to full parity with the ALL tab. --- **8.E — Add-Track buttons in all modes, medium-aware routing** - **What:** Every Release Archive tab surfaces an Add Track button that routes to the upload page pre-set to that tab's medium. The ALL-tab Add Track defaults to Cut; the medium selector stays user-changeable after landing on the form. - **Why:** Before 8.E, the upload form had no direct link from the Release Archive tabs. An admin starting from the Sessions tab had no in-context Add Track button pointing at a Session upload. - **Shape:** `TrackList.razor` gained a `MudStack` Add Track button above `MudTabs` in the `Albums` browse arm (§8.A's tab strip), `@bind-ActivePanelIndex="_activeTabIndex"`, and two helpers: `ActiveMedium` maps tab index 0 (ALL) → `ReleaseMedium.Cut` and index ≥1 → `Enum.GetValues()[index-1]`; `AddTrackHref(medium)` → `/tracks/upload?medium={medium.ToString().ToLowerInvariant()}`. `BatchUpload.razor` reads `?medium=` via `[SupplyParameterFromQuery(Name = "medium")]`, parses with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined`, defaults to `ReleaseMedium.Cut`, and routes through the existing `OnMediumChanged` so the pre-selected medium drives the conditional fields on load (the 8.F hero field for Session, `ReleaseType` for Cut) and the 8.L single-track name-collapse runs identically to a user change. The selector stays user-changeable after landing; `/tracks/upload` with no param still defaults to Cut. No `@rendermode` override; no constructor growth; no `IServiceProvider`. `TrackList.razor` edits confined to the tab-strip toolbar (no grid-component / `MediumGrid` switch edits). Files modified: `TrackList.razor`, `BatchUpload.razor`. **Completion note:** `TrackList.razor` gained `_activeTabIndex` backing field with `@bind-ActivePanelIndex`, `ActiveMedium` computed property (index 0 → `Cut`; index ≥1 → `Enum.GetValues()[index-1]`), `AddTrackHref(medium)` static helper producing `/tracks/upload?medium={…}`, and a `MudStack` row above `MudTabs` rendering the medium-aware Add Track button. `BatchUpload.razor` gained `[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }` and seed logic in `OnInitializedAsync`: if `MediumParam` is set, `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` gate the call to `OnMediumChanged(medium)`, driving conditional fields and 8.L name-collapse on load without requiring a user gesture. The query-param convention is new to the codebase as a Blazor `[SupplyParameterFromQuery]` entry, mirroring the existing API-side `Enum.TryParse`/`IsDefined` parse posture. Acceptance criteria met; Wave 8 track 8.E surfaces a medium-aware Add Track button in every Release Archive tab. --- **8.H — Archive page becomes the searchable all-releases browser (release-cardinal, decided H2)** - **What:** Replace the public `/archive` three-card overview with a release-cardinal searchable browser over all releases. Retire the three-card overview on every breakpoint; cascade: `/tracks` (`TracksView`) is demoted from the nav (route kept reachable); mobile ARCHIVE → the new browser. - **Why:** The three-card overview is dead weight — it merely summarizes what the site offers without letting the user interact with actual content. The real archive experience is discovering and exploring releases across all media with search, filtering, and per-medium detail pages. A searchable all-releases browser is what "archive" means to a listener. - **Shape:** New `ArchiveView` (`.razor` + `.razor.cs` + `.razor.css`): debounced Title/Artist search, an enum-driven medium filter (`All` + per-medium from `Enum.GetValues()` + a label lookup, so a fourth medium surfaces from one entry), and a genre filter sourced from the existing distinct-genres list. Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}` (the established `AlbumsView` Cut destination, since Cuts have no single-release detail page). The unfiltered first page is bridged across the prerender→WASM seam via `PersistentComponentState` (keyed `"archive-releases"`, persisted/restored only when no filter is active), matching the `TracksView`/`AlbumsView` pattern. No page-level `@rendermode` override. **API surface grew (additive, backward-compatible):** new `ReleaseFilter` DTO (`SearchText`, `Genre`, `IsEmpty`) mirroring `TrackFilter`; `q` + `genre` query params threaded through `ReleaseController` → `ReleaseProxyController` → `ReleaseClient`/`IReleaseDataService`/`ReleaseClientDataService` and `ReleaseManager`/`IReleaseService`/`ReleaseRepository.GetPagedByMediumAsync`. Search uses parameterized `EF.Functions.ILike` over Title/Artist (Npgsql); genre is exact-match. No constructor growth, no `IServiceProvider` — optional params on existing signatures. New test `ReleaseBrowseQueryTests` covers the repository query path (medium/genre/compose/null-passthrough/soft-delete; the `ILike` search is a Postgres-DSN-gated integration test that skips without a DB). - **Acceptance criteria:** `/archive` is a searchable, filterable all-releases browser with debounced search, medium and genre filters; cards navigate to correct per-medium detail routes; unfiltered first load is prerendered and bridged via persistent state; existing `/tracks` route stays reachable but is removed from public nav; no three-card overview remains. **Completion note:** `ArchiveView` (at `/archive`) rewritten in place from the three-card overview to a release-cardinal searchable browser. New `ArchiveView.razor`, `ArchiveView.razor.cs`, `ArchiveView.razor.css` implemented with debounced search (Title/Artist), medium filter (enum-driven, no hardcoded switch), and genre filter (sourced from distinct-genres list). Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}`. Unfiltered first load persisted/restored via `PersistentComponentState` keyed `"archive-releases"` (following `TracksView`/`AlbumsView` pattern). New `ReleaseFilter` DTO added with `SearchText`, `Genre` (string, optional), `IsEmpty` (bool). `ReleaseController` extended with `q` and `genre` optional query params on `GetPagedByMedium` endpoint; params threaded to `ReleaseProxyController` and down through data-service layers. Repository method refactored: `GetPagedByMediumAsync` now accepts optional `searchText` and `genre` parameters, applies parameterized `EF.Functions.ILike` for search over Title/Artist (Npgsql), exact-match for genre. New integration test `ReleaseBrowseQueryTests` covers medium filter, genre filter, compose, null passthrough, soft-delete; `ILike` search integration-only, skips without Postgres DSN. Old `/archive` three-card overview removed. API surface backward-compatible (all new query params optional, existing `medium` filter unchanged). Navigation structure unchanged; `/tracks` (`TracksView`) remains in nav and routable (demotion from nav, and removal of GENRES, are explicit work items for track 8.I). Three-card overview fully retired; public archive is now the searchable all-releases browser; acceptance criteria met. --- **8.I — Nav slimmed: ARCHIVE + three medium modes inline, GENRES removed** - **What:** Above the medium breakpoint the appbar carries ARCHIVE (the new release-cardinal browser) and the three medium modes (CUTS/SESSIONS/MIXES) as direct inline links. GENRES removed from the nav. `/tracks` (`TracksView`) demoted from the nav (route kept reachable). - **Why:** The nav was cluttered with redundant levels (ARCHIVE popover + separate Tracks/Genres entries). Flattening the medium links into the appbar alongside ARCHIVE streamlines navigation; removing GENRES (while keeping the route) reduces clutter. The real archive is release-cardinal (8.H); the `/tracks` track-cardinal gallery is no longer the primary public browse surface. - **Shape:** `Pages.cs` `MenuPages` removes GENRES and `Tracks` entries; keeps ARCHIVE (now linking to the searchable all-releases browser per 8.H) with no children in the menu model (the three media become inline appbar siblings). `DeepDrftMenu.razor` flattens ARCHIVE and the three medium items into inline `` siblings above the `sm` (600px) breakpoint; the mobile renderer keeps ARCHIVE with the three media indented in the hamburger drawer. The desktop hover popover (`.dd-nav-dropdown`, `:hover`/`:focus-within` CSS, dead-code collapse/reset machinery from 8.J) is removed as now-dead code — no desktop popover renders at any width ≥600px, and the only surviving popover surface (mobile drawer) already dismisses on child click via `CloseMobileMenu`. Code review verified: no desktop popover regression at any breakpoint, mobile drawer dismiss unchanged. **Completion note:** `Pages.cs` `MenuPages` trimmed: **Tracks** and **Genres** entries removed; ARCHIVE retains its three medium children (Cuts/Sessions/Mixes) unchanged as the single nav data shape — no duplication or child nulling. `/tracks` and `/genres` routes remain reachable by direct URL. `PageRoute.HasChildren` is now unreferenced but left in place. `DeepDrftMenu.razor` refactored: above `sm` breakpoint the renderer builds a flat `