Files
deepdrft/COMPLETED.md
T
2026-06-19 16:48:48 -04:00

322 KiB
Raw Blame History

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.3 — Fixed embed panel + iframe resize (landed 2026-06-19)

Landed: 2026-06-19 on dev.

  • What: The Fixed (embed) mode queue panel and the OQ1 Option-A iframe resize handshake. Release embeds now render an always-shown, read-only queue panel below the player-bar controls; the Queue button collapses/expands that panel and posts the iframe's new height to the host page so the outer <iframe> element resizes to match. Single-track embeds (TrackEntryKey mode) have no queue, no panel, and no Queue button — unchanged compact behaviour. Phase 17 is now complete (all four waves landed).

  • Why: Phase 11 wave 11.F armed release embeds with a queue (skip navigation, auto-advance), but the viewer had no way to see or jump within the queue. Wave 17.3 surfaces it in Fixed mode — read-only because a shared embed is not an editable playlist — and resolves OQ1 (Option A confirmed feasible: postMessage resize degrades gracefully if the host strips the script).

  • Shape:

    • Fixed embed queue panel (AudioPlayerBar.razor): rendered conditionally on ShowFixedPanel && _fixedPanelOpen inside .deepdrft-queue-embed-panel; hosts <QueueList Items="QueueItems" CurrentIndex="QueueCurrentIndex" Editable="false" OnJump="@OnQueueJump" />. Read-only: no drag handles, no remove buttons. Row-jump (OQ2) calls PlayRelease(Items, index) — coherent from the armed-but-not-started state (PlayRelease already clears IsArmed and materializes a defensive copy).
    • Queue button in Fixed mode (PlayerTransportZone): toggles _fixedPanelOpen; triggers a height post after the panel renders. Gated on ShowFixedPanel so single-track embeds see no button.
    • EmbedSnippetBuilder.cs (DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs): ForRelease now mints a per-snippet random token (8 hex chars from Guid.NewGuid().ToString("N")[..8]). Token is used as the iframe id (deepdrft-embed-{token}) and threaded into the iframe src as &EmbedId={token}. Taller iframe height (release: 384 px vs. track: 196 px). Carries a host-side <script> listener that matches incoming {type:"deepdrft-embed-resize", embedId} messages against the snippet's own token and sets iframe.style.height — multiple release embeds on one host page resize independently (no cross-talk). Degrades to Option B if the host strips the script (panel still works inside the iframe at expanded height). ForTrack is unchanged (compact height 196 px, no script, no id token).
    • embed-frame.ts (DeepDrftPublic/Interop/embed/embed-frame.ts; compiled output gitignored): new TypeScript interop module. Reads EmbedId from window.location.search once at module load; exports postHeight(element: HTMLElement) — measures the player element's rendered height (Math.ceil(getBoundingClientRect().height) + 2), builds {type:"deepdrft-embed-resize", height, embedId?} payload (omits embedId when absent for backward-compatible degradation), and calls window.parent.postMessage(payload, "*"). No-ops when not framed (window.parent === window) or the element is unmeasurable.
    • CSS (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css): new deepdrft-queue-embed-panel and related deepdrft- embed-panel classes for the fixed queue panel chrome.
    • Tests (EmbedSnippetBuilderTests): height divergence (ForRelease taller than ForTrack), ForTrack-unchanged (height 196, no script), id uniqueness (two ForRelease calls yield distinct ids), id/script-token consistency (iframe id matches token in script), EmbedId-in-src (token appears as EmbedId= in the iframe src).

Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)

Landed: 2026-06-19 on dev.

  • What: The append-only Add-to-Queue affordance on detail pages — a new shared AddToQueueButton.razor control wired at every detail-page play site, enabling listeners to add a release or individual track to the queue without interrupting the current track. ReleaseGallery browse-grid cards are intentionally excluded (OQ10, deferred).

  • Why: Phase 11 wave 11.F built the Enqueue/EnqueueRange append path in the queue engine but gave it no UI entry point. Wave 17.4 lights that dormant path, completing the Add-to-Queue capability Daniel stated as commitment 4 of Phase 17. It was split from 17.2 because it depends only on the existing engine append members (not on 17.1's new Move/RemoveAt), allowing it to land in parallel.

  • Shape:

    • AddToQueueButton.razor (DeepDrftPublic.Client/Controls/AddToQueueButton.razor): shared append-only button with two modes: track mode (Enqueue — called with a single TrackDto) and release mode (EnqueueRange — called with an ordered IReadOnlyList<TrackDto>). Material PlaylistAdd glyph; tooltip reads "Add to queue" (track mode) or "Add release to queue" (release mode). Reads the cascaded IQueueService; disabled until interactive / when the cascade is absent; append-only — does not play, does not navigate.
    • CutDetail.razor (header): release-mode AddToQueueButton beside the header play affordance, passing the TrackNumber-ordered track list.
    • CutDetail.razor (track rows): track-mode AddToQueueButton beside the per-row play affordance.
    • SessionDetail.razor (hero play): track-mode AddToQueueButton beside the Session hero play button.
    • MixDetail.razor (hero play): track-mode AddToQueueButton beside the Mix hero play button.
    • Excluded sites: StreamNowButton (no fixed track to resolve — OQ9) and ReleaseGallery cards (no play button today — OQ10, deferred to TODO.md).

Phase 17 — Player-Bar Queue View: Wave 17.2 — Docked queue overlay (landed 2026-06-19)

Landed: 2026-06-19 on dev.

  • What: The editable docked-player queue overlay — a Queue toggle button in the non-Fixed (docked) player bar and a new QueueOverlay.razor modal that hosts the shared QueueList in editable mode. Listeners can now see, reorder, remove from, and jump within the queue while a release is playing. Also fixed a pre-existing QueueChanged unsubscribe leak in AudioPlayerBar.DisposeAsync, hardened PlayRelease with a defensive copy, and styled the global deepdrft-queue-* CSS classes for the first time (first styling for the QueueList classes that 17.1 shipped unstyled).

  • Why: Phase 11 built the queue engine and Phase 17 wave 17.1 built the shared QueueList component, but neither surfaced the queue visually in the docked player. Wave 17.2 delivers Daniel's commitment 2 — a visible, editable queue panel in the non-Fixed player bar.

  • Shape:

    • Queue toggle button (AudioPlayerBar / PlayerTransportZone): shown only when !Fixed && Items.Count > 0, placed below the transport-button row and left of the timestamp. Material QueueMusic glyph; renders in an active/highlighted state when the overlay is open.
    • QueueOverlay.razor (DeepDrftPublic.Client/Controls/QueueOverlay.razor): screen-centered tinted modal borrowing the WaveformVisualizerControlPopover MudOverlay idiom (DarkBackground="true", Modal="true"). Panel stops click propagation; scrim-click closes the overlay (drag-safe: capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes if a removal empties the queue. Hosts QueueList in Editable="true" mode.
    • AudioPlayerBar wiring: reorder → Move(fromIndex, toIndex); remove → RemoveAt(index) (auto-closes overlay when queue empties); row-jump → PlayRelease(Items, index); Clear header action → new ClearUpcoming(). Fixed pre-existing QueueChanged unsubscribe leak in DisposeAsync.
    • QueueList.razor — current-row remove suppression: the remove (×) control is now hidden on the currently-playing row (Editable && !isCurrent), enforcing OQ3's "the current track cannot be removed" rule in the UI. Reorder of the current row is still permitted.
    • Engine — ClearUpcoming() (IQueueService / QueueService): new additive member. Removes all queued items except the currently-playing one, leaving it as the sole item at CurrentIndex == 0; re-emits QueueChanged; touches no playback. Satisfies OQ5's requirement that Clear does not stop or remove the current track.
    • Engine — PlayRelease defensive copy: PlayRelease now always materializes a defensive copy of its input list (tracks.ToList()) so it can never alias the caller's Items list — fixes a row-jump bug where jumping via PlayRelease(Items, index) could mutate the live Items reference mid-operation.
    • CSS — deepdrft-queue-* classes (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css): overlay/list chrome classes added to the global stylesheet (portaled overlay content cannot use scoped CSS). This is also the first styling pass for the QueueList classes 17.1 introduced without accompanying styles.

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 <div> (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<int> OnRemove, and EventCallback<int> 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: T1T10 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.5 — Home Plays-card capstone (landed 2026-06-19)

Landed: 2026-06-19 on dev.

  • What: The capstone wave — the live home hero Plays card and the HomeStatsDto extension that powers it. NowPlayingStats.razor's third card, previously a static "XXX / Plays (Coming Soon)" odometer placeholder, now renders the live TotalPlays figure in the existing odometer treatment with a secondary "N listeners" line (UniqueListeners). No new fetch path, no new client service, no migration — the card consumes the same HomeStatsDto round-trip the other two cards already use. Privacy footer line (DeepDrftFooter.razor, .deepdrft-footer-privacy) also landed as part of the same merge: a quiet fine-print disclosure of the anonymous anonId token, using the Variant 1 approved copy from product-notes/phase-16-privacy-note.md.

  • Why: The Plays card was deliberately held as the final wave (sequenced bottom-up per Daniel's directive) so the substrate (16.1 capture + rollup, 16.2 bucket/channel, 16.3 anonId + distinct-listener aggregation) would be solid before any read surface appeared. Wave 16.4 (per-target / CMS stats views) was speculative and skipped; the event log supports it later if wanted. With 16.5 landing, Phase 16 is complete.

  • Shape:

    • HomeStatsDto extended (DeepDrftModels/DTOs/HomeStatsDto.cs): two new fields — TotalPlays (long; site-wide sum of every play_counter row's PartialCount + SampledCount + CompleteCount, all-time; zero until the telemetry migration is applied — expected, not an error) and UniqueListeners (int; distinct non-null anon_id across all play events, all-time; over-counts by design, honestly labelled "listeners"). No other DTO changes.
    • StatsController composition (DeepDrftAPI/Controllers/StatsController.cs): now injects ITrackService (existing) and IEventService (Phase 16 event domain). GetHome assembles HomeStatsDto in two sequential best-effort reads: track-domain aggregation via ITrackService.GetHomeStats (existing; failure returns 500 as before); play/listener figures via IEventService.GetTotalPlayCount and IEventService.GetDistinctListenerCount (Phase 16; a telemetry failure or not-yet-applied migration leaves them at 0 rather than 500-ing the whole endpoint). Neither domain reaches into the other's tables; the controller is the composition seam only.
    • IEventService additions (DeepDrftData/IEventService.cs): GetTotalPlayCount(ct)ResultContainer<long> and GetDistinctListenerCount(ct)ResultContainer<int> (wave 16.3 added the distinct-listener overloads; GetTotalPlayCount is the one new member for 16.5).
    • EventRepository.CountTotalPlaysAsync (DeepDrftData/Repositories/EventRepository.cs): sums PartialCount + SampledCount + CompleteCount directly over PlayCounters via LINQ — not PlayCounter.TotalPlays (which is an EF-ignored computed property and not translatable). An empty counter table sums to 0.
    • NowPlayingStats.razor (DeepDrftPublic.Client/Controls/NowPlayingStats.razor): third card now renders @_stats.TotalPlays in .hero-stat-odometer and @_stats.UniqueListeners listeners as .hero-stat-sub. No change to the PersistentComponentState bridge or IStatsDataService fetch path — the DTO fields arrive in the same existing round-trip.
    • DeepDrftFooter.razor (DeepDrftPublic.Client/Layout/DeepDrftFooter.razor): privacy disclosure paragraph (.deepdrft-footer-privacy) added: "We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag's gone."
    • No migration. The play_counter rollup table was created by 20260619155610_AddPlayShareTelemetry (wave 16.1; authored, not yet applied — Daniel-gated). The CountTotalPlaysAsync query returns 0 gracefully until that migration runs.

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 3080%, 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<int>. 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 (3080%), 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, trackrelease 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.GetHomeStatsAsyncHomeStatsDto (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 <p class="bio-body"> — 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.razorMudPopover 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=<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=<medium>; CmsAlbumBrowser expanded child-row track table gains waveform-status columns + info tooltip + OnWaveformGenerated EventCallback; ICmsTrackService / CmsTrackServiceGetGenreSummariesAsync 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 <HierarchicalRoleAuthorizeView>: Authorized<RedirectToCatalogue />; 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. MixWaveformVisualizerWaveformVisualizer, MixVisualizerControlsWaveformVisualizerControls, MixVisualizerControlStateWaveformVisualizerControlState, MixZoomMappingWaveformZoomMapping, MixVisualizer.tsWaveformVisualizer.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). MixWaveformResolutionWaveformResolution. Vault mix-waveformstrack-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 <WaveformVisualizer> 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, <WaveformVisualizer Fill="true"> + <WaveformVisualizerControlPopover>. 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.csComputeAndStoreHighResAsync added, medium-neutral. DeepDrftContent/Constants/VaultConstants.csTrackWaveforms = "track-waveforms". DeepDrftAPI/Controllers/TrackController.csGET api/track/{trackEntryKey}/waveform/high-res (unauthenticated) + POST api/track/{trackId}/waveform/high-res (ApiKey, generalized generate); WaveformStatusDto.HasHighRes populated. DeepDrftAPI/Services/UnifiedTrackService.csUploadAsync now calls ComputeAndStoreHighResAsync for every new track. DeepDrftPublic/Controllers/TrackProxyController.cs — proxy for the new high-res endpoint.


Phase 10 — Mix Visualizer Reframe: Waves R1R4 (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 (~1632 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 R4MixVisualizerControlState 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 RadialKnobs; 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 60110% band; gravity 075%; heat +20% at top; width default 50% / range 1095%. 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 RadialKnobs, Visible parameter gates knob band via @if while container holds reserved height. DeepDrftShared.Client/Common/DDIcons.csLavaLamp 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.A11.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 <SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" />.

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<ReleaseDto, string>? HrefResolver { get; set; } and [Parameter] public Func<ReleaseDto, string>? 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<TrackDto> 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<TrackDto>, 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 <div class="release-hero-img"> (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 <ReleaseHeroOverlay ... />; 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 <ReleaseHeroOverlay Class="mix-hero" ... /> 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.cssmix-hero square/medium sizing override added; .mix-detail-cover removed. ReleaseDetailScaffold.razorbool 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<ReleaseDto>? 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<ReleaseDto>? RowActions { get; set; } slot. In its place: [Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } (defaulting to Array.Empty<SpecialActionColumn>()). SpecialActionColumn is a new sealed record (string Header, RenderFragment<ReleaseDto> Cell) in DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs. The grid renders one dedicated <MudTh> per declared column (between the Tracks header and the Actions header) and one <MudTd> 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.razorRowActions 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<ReleaseDto> Cell). CmsMixBrowser.razorRowActions 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.A8.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 17) 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.A8.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<ReleaseMedium>() 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<ReleaseMedium, string> 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 <InputFile> UX) lets an admin author a complete Session in one submission.
  • Shape: SessionFields.razor renders a deferred hero-image <InputFile> (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 <InputFile> 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 <InputFile> 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<string> _collapsedDropdowns = [] keyed by navPage.Route; parent <li> gets the class via _collapsedDropdowns.Contains(navPage.Route). Child link's @onclick calls CollapseDropdown(navPage.Route) (adds route to set); parent <li>'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 <MudTextField> 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<ReleaseDto>? 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<ReleaseDto>) 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<ReleaseDto>? 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<TRow> (generic, abstract) was rewritten: it now loads a medium-filtered release list via ICmsReleaseService.GetPagedAsync, projects to a bare IReadOnlyList<ReleaseDto> 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<ReleaseMedium>()[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<ReleaseMedium>()[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<ReleaseMedium>(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<ReleaseMedium>() + 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 ReleaseControllerReleaseProxyControllerReleaseClient/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 <a class="dd-nav-link"> 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 <ul> of ARCHIVE + Cuts/Sessions/Mixes as inline <a> nav links (no popover nesting); below sm breakpoint the mobile <ul> keeps ARCHIVE as a parent with indented media children (existing drawer pattern, unchanged). DeepDrftMenu.razor.css removes .dd-nav-dropdown (hover popover display), .dd-nav-item-parent (parent hover state), and .dd-nav-item-collapsed (popover collapse toggle from 8.J). Remaining CSS is the link and mobile-drawer base styles. The collapse/reset JavaScript state and methods (_collapsedDropdowns, CollapseDropdown, ResetDropdown from 8.J) are removed as unreferenced once the popover disappears. Files: Pages.cs, DeepDrftMenu.razor, DeepDrftMenu.razor.css. All acceptance criteria met: ARCHIVE and three media are inline appbar links at desktop breakpoint; GENRES removed from nav while /genres route remains reachable; /tracks demoted from nav while route remains reachable; mobile drawer keeps ARCHIVE + media sub-list; no popover floats at any breakpoint; no nav regression.


8.M — Retire the legacy single-track CMS forms

  • What: Retire TrackNew (/tracks/new) and TrackEdit (/tracks/{Id:long}) as standalone authoring forms in DeepDrftManager. Their responsibility is absorbed by BatchUpload / BatchEdit's single-track branch.
  • Why: The legacy forms were a duplicate code surface. Folding their function into the batch forms reduces form surface and removes the addressing-model gap that existed between TrackEdit (addressed by track id) and BatchEdit (addressed by release title). Daniel's decision: "consolidate the forms and reduce the code surface" (2026-06-13).
  • Shape: Option 2 (Daniel's decision): BatchEdit gained a track-addressed route /tracks/{TrackId:long}/edit that resolves the track to its parent release via GetByIdAsync, loads the release through the existing release-load path, and pre-selects the addressed track's row (ResolveInitialSelection matches by Id, falls back to row 0). The existing release-title route (/tracks/album/{AlbumName}/edit) is untouched. The two legacy components were reduced to thin redirect shims (not hard-deleted, to guard bookmarks): /tracks/new/tracks/upload; /tracks/{Id}/tracks/{Id}/edit. CmsTrackGrid's per-row Edit now targets /tracks/{id}/edit. Files changed (6): BatchEdit.razor, BatchUpload.razor, CmsTrackGrid.razor, SessionFields.razor, TrackEdit.razor, TrackNew.razor. No new component, no public-site change, no constructor growth, no IServiceProvider.

Completion note: BatchEdit.razor gained a second @page route /tracks/{TrackId:long}/edit; OnInitializedAsync uses GetByIdAsync when TrackId is set, resolves the parent release, loads through the existing release-load path, and calls ResolveInitialSelection which matches by Id (falls back to row 0) to pre-select the addressed track's row. The existing /tracks/album/{AlbumName}/edit route and its load path are untouched. TrackEdit.razor and TrackNew.razor were each reduced to thin redirect shims — TrackNew redirects /tracks/new/tracks/upload; TrackEdit redirects /tracks/{Id}/tracks/{Id}/edit — preserving inbound bookmarks without keeping dead form logic. CmsTrackGrid.razor per-row Edit link updated from /tracks/{id} to /tracks/{id}/edit. SessionFields.razor and BatchUpload.razor received minor coordinating edits. Build clean. No automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference — consistent with prior Wave 8 tracks). All acceptance criteria met; legacy TrackNew/TrackEdit authoring forms retired; track-addressed edit route live on BatchEdit.


8.K — Mix Visualizer redesign (post-Phase-9 wave)

  • What: Replace the static SVG waveform silhouette on the Mix detail page with a windowed, playback-coupled, bottom-to-top scrolling Canvas 2D animation; simultaneously switch Mix loudness datum capture from a fixed 2048-bucket count to a duration-derived constant-time-resolution scheme. Strictly read-only (no seek seam); theme-aware glassy gradient aesthetic (lava-lamp idiom, MudBlazor palette, live dark-mode responsive).
  • Why: The static SVG silhouette did not communicate playback progress or the shape of the material at any useful zoom level. Long mixes were under-sampled at fixed 2048 buckets — the visualizer design called for ~333 samples/sec so max-zoom detail is legible. The redesign gives Mix detail pages their signature dynamic visual and makes the waveform datum meaningfully dense.
  • Shape: Two waves. Wave 1 (datum §F): bucket count becomes ceil(durationSeconds × 333), clamped [2048, 2_000_000]; pure helper MixWaveformResolution.cs (BucketCountForDuration, named constants SamplesPerSecond/MinBucketCount/MaxBucketCount); UnifiedReleaseService.TriggerMixWaveformAsync derives the count from audio.Duration; fixed MixWaveformBucketCount = 2048 constant removed. Single high-density datum (not tiered/mipmap — Daniel's decision). Backward-compatible: existing 2048-bucket mixes still render coarsely; re-running the Generate trigger re-captures at new density. Wave 2 (renderer §A/B/C/D/E): MixWaveformVisualizer rewritten from static SVG to Canvas 2D scrolling animation driven by a requestAnimationFrame loop in new TS interop module MixVisualizer.ts (DeepDrftPublic/Interop/visualizer/). Guitar-Hero zoom coupling anchored at 0.333 s (1 quarter note @ 180 BPM max-zoom), range 0.333 s → 30 s, default-open 10 s. rAF loop gated on is-playing (idle on pause; one-shot redraws on zoom/theme/datum/resize while idle). Sample↔time mapping uses the DTO's BucketCount and the mix duration (sourced from the cascaded player, gated to the mix's TrackId) — no fixed-2048 assumption. New MixZoomMapping.cs (pure log-scaled zoom↔seconds) and MixVisualizerZoomState.cs (scoped, session-persistent, resets on fresh load, registered in Startup.cs); MixDetail.razor passes TrackId. Inert OnSeek + two-way PlaybackPosition seam dropped; PlaybackPosition is one-way input; ReleaseId self-fetches the datum. No @rendermode override, no constructor growth, no IServiceProvider; component CSS scoped.

Completion note: Wave 1 landed: DeepDrftContent/Processors/MixWaveformResolution.cs (new, pure helper with BucketCountForDuration, SamplesPerSecond = 333, MinBucketCount = 2048, MaxBucketCount = 2_000_000); UnifiedReleaseService.TriggerMixWaveformAsync derives bucket count from audio.Duration via the new helper; fixed MixWaveformBucketCount = 2048 constant removed. WaveformProfileDto.BucketCount now varies per-mix. 8 unit tests in MixWaveformResolutionTests.cs. Wave 2 landed: MixWaveformVisualizer rewritten as a Canvas 2D scrolling component; DeepDrftPublic/Interop/visualizer/MixVisualizer.ts (new TS module) owns canvas, datum decode, rAF loop, scroll/zoom/compositing math, and dark-mode responsive theming. MixZoomMapping.cs and MixVisualizerZoomState.cs (new); zoom state registered as scoped in Startup.cs; MixDetail.razor passes TrackId. Two-way PlaybackPosition binding dropped; one-way input only. No automated tests for Wave 2 (DeepDrftTests references DeepDrftContent/DeepDrftData, not DeepDrftPublic.Client — consistent with prior public-site UI waves). Design spec: product-notes/phase-9-mix-visualizer-redesign.md. All acceptance criteria met; Wave 8 track 8.K completes the Mix Visualizer redesign and closes Wave 8 in full.


9.6 Wave 6 — Gap Closure

Landed: 2026-06-13 on dev.

Two functional gaps the landed Phase 9 surface left open. Both are real (medium intent not honoured at a surface that should honour it), neither is debt. A is a product decision (which destination the home-page cards take) and is gated on Daniel — its build is one line of markup either way, but the shape of the answer is his to pick. B is clear-cut (mirror an existing collapse already proven on the upload path into the edit path). A and B are independent; B can land immediately, A waits on the open question below.

9.6.A — Home-page editorial cards have no medium destinations

  • What: The three "Music through Every Medium" editorial cards on Home.razor (Studio / Live / DJ Mix — landed §8.6) still render as non-navigating <div>s. They carry a deferral comment — @* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@ — written before the medium browse routes existed. Today /cuts, /sessions, /mixes are live and working (§9.4); the only thing that points anywhere from this section is the section CTA "Explore the Archive" → /tracks. The cards are the most prominent medium framing on the public site and they are dead ends.
  • Why it matters: This section is the home page's pitch of the three-medium taxonomy. Leaving the cards inert undercuts the whole Phase 9 narrative — a visitor reads "Studio / Live / DJ Mix," clicks the most prominent thing on the page, and nothing happens. The destinations now exist; the only question is which destination is right. The TODO's /tracks?format= premise is very likely obsolete — it predates the medium browsers, which already give each card a real home.
  • Shape: Depends on the open question. Either is small:
    • (a) Link the three cards to the existing medium browsers — Studio → /cuts, Live → /sessions, Mix → /mixes. Promote each .medium-card <div> to an <a href> (the §8.6 spec already anticipated this: "promoting to <a> later is a one-line change" — the hover styles assume the affordance). Zero new surface; the routes exist today. Removes the stale TODO.
    • (b) Build a /tracks?format=<medium> filtered gallery first, then point the cards there — a flat cross-medium gallery pre-filtered by medium (grid/list toggle, the TracksView ergonomics), distinct from the medium-specific browsers. Honours the original TODO's literal premise but adds a surface that does not exist yet: a format/medium query param on TracksView + its VM, plus the routing. The cards then deep-link into that one gallery, pre-filtered.
  • Acceptance criteria: Each of the three editorial cards navigates to a live medium destination on click (desktop and mobile); the stale /tracks?format= TODO is resolved (removed under (a), or satisfied under (b)); no card remains a dead <div>.
  • Open question (Daniel — product decision, do not pre-empt): Should the cards point at the existing medium browsers (/cuts / /sessions / /mixes, shape (a)) or at a new /tracks?format= filtered gallery (shape (b))?
    • (a) is trivial and honest about what the site already offers — the medium browsers are the canonical per-medium surfaces, and a card that says "Studio Releases" landing on /cuts is exactly truthful. The TODO that asked for /tracks?format= was written before those browsers existed and is plausibly just stale.
    • (b) adds a surface but unifies the browse experience under one flat gallery the visitor can re-filter in place — the card is an entry point into a single explorable gallery rather than three sibling destinations. Worth it only if Daniel wants the flat cross-medium gallery to be the primary public browse model rather than the medium-specific browsers.
    • Note: (a) requires no new code beyond the three hrefs (and the <div><a> promotion the §8.6 spec pre-authorised); the /cuts, /sessions, /mixes routes already satisfy it. (b) is a genuine new view. The choice is Daniel's — it is a question of which browse model the home page should funnel into, not an implementation detail.

9.6.B — BatchEdit single-track form-shape collapse not applied on the edit path

  • What: BatchUpload.razor enforces the single-track invariant (§9.3 resolved: Session/Mix are one-track-per-release) by collapsing its multi-track master list to a single row when the medium is Session or Mix — OnMediumChanged trims the form to row 1. The edit path BatchEdit.razor (/tracks/album/{AlbumName}/edit) was not given the same collapse; a code comment flags the deferral. Opening a Session or Mix release in BatchEdit today shows the full multi-track master list — a form shape that, by the phase's own resolved invariant, should not exist for those media.
  • Why it matters: The edit form contradicts the data model it edits. Sessions and Mixes are single-track by design and the upload path already enforces that; the edit path showing a multi-track list invites an admin to add tracks to a release that is not supposed to have them, and presents an inconsistent authoring experience between create and edit for the same medium. It is the upload-path invariant left half-applied.
  • Shape: Mirror BatchUpload's OnMediumChanged collapse logic into BatchEdit. When the loaded (or selected) medium is Session or Mix, collapse the master list to a single track row and hide the add-track affordance, exactly as BatchUpload does — BatchUpload.OnMediumChanged is the reference implementation; reuse its shape rather than authoring a second one (the collapse logic is a candidate to lift into a shared helper or the MediumFields dispatch if it reads cleanly, but parity with upload is the requirement, shared extraction is the nicety). The medium selector wiring into BatchEdit's submit path already landed in §9.5.B; this is the form-shape half that did not.
  • Acceptance criteria: Opening a Session or Mix release in BatchEdit shows a single-track form with no add-track affordance, matching BatchUpload for the same medium; opening a Cut release is unchanged (full multi-track list); switching the medium selector to Session/Mix within BatchEdit collapses the list live, the same gesture BatchUpload performs.
  • Open question: How should BatchEdit render an existing Session/Mix release that already holds multiple tracks (e.g. one created before the §9.3 single-track invariant landed, or mis-authored)? Collapsing the form to row 1 would visually hide tracks 2..n without deleting them — the admin sees one track, the DB holds several, and a save could silently orphan the editing of the hidden tracks. Recommend the safe reading: if a non-Cut release loads with >1 live track, do not silently collapse — show the full list with an inline warning ("Sessions and Mixes are single-track; this release has N — remove extras to conform") and let the admin reconcile, only enforcing the single-row collapse once the release is already conformant. This keeps the invariant from destroying data it was added after. Flag for Daniel; the collapse-on-conformant-release behaviour (the common case) is unambiguous and can land regardless.

Dependency summary for Wave 6: A and B are independent. B is unblocked and clear-cut (mirror the proven BatchUpload collapse). A is blocked only on the Daniel product decision above — once the destination is chosen, its build is trivial. Neither depends on the other.

Completion note: 9.6.A — Home-page editorial cards on Home.razor linked to medium-specific browsers (decision (a) implemented): Studio → /cuts, Live → /sessions, Mix → /mixes. Each .medium-card <div> promoted to <a href> navigating to the corresponding route; stale /tracks?format= TODO removed. 9.6.B — BatchEdit.razor single-track form-shape collapse mirrored from BatchUpload.OnMediumChanged: when loaded or selected medium is Session or Mix, master list collapsed to single row with no add-track affordance, matching upload-path invariant. The open question about existing multi-track Session/Mix releases was resolved by Daniel as straight collapse, no warning path — Phase 9 is unmerged so zero legacy multi-track data exists; the collapse logic in OnInitializedAsync (lines 197200) and OnMediumChanged (lines 143151) silently trims to one track on load and on selector change with no defensive UI. Wave 6 closes functional gaps in Phase 9 medium taxonomy surface; no regressions, both items clarify intent where taxonomy did not yet reach.


9.4 Wave 4 — Public site: ARCHIVE nav, CUTS / SESSIONS / MIXES, waveform visualizer

Landed: 2026-06-13 on dev.

  • 9.4.A — ARCHIVE nav + popover.
    • What: Replace the current RELEASES / SESSIONS / MIXES nav items (in DeepDrftPublic.Client/Layout/Pages.cs) with a single ARCHIVE item. Desktop: hover shows a MudBlazor popover with CUTS / SESSIONS / MIXES → /cuts, /sessions, /mixes. Mobile / direct nav: ARCHIVE → an overview page /archive (three medium cards, reusing the §8.6 card idiom). Fixes the current dead Sessions/Mixes links.
    • Why: The nav must route into the new medium surfaces; today's Sessions/Mixes links point nowhere.
    • Shape: DeepDrftMenu.razor renders Pages.MenuPages as a flat <a> list today with no dropdown mechanism. Recommend extending the nav model with an optional Children collection (generalizes to future dropdowns) over a bespoke hardcoded popover. Pinned semantics (spec §5.1): dual-role nodes — desktop hover opens children, desktop click navigates to the parent's route (/archive), mobile renders the parent as a link with children indented; depth cap of one level — deeper nesting is a redesign, not a recursion.
    • Acceptance criteria: ARCHIVE replaces the three flat items; desktop hover reveals the three sub-links; mobile routes to /archive; no dead links remain.
  • 9.4.B — CUTS (/cuts).
    • What: New /cuts route reusing the existing AlbumsView layout, filtered to Medium == Cut. Studio Singles/EPs/Albums appear as they do on the current Releases page.
    • Why: Honour the existing studio-release browse under the new medium taxonomy. Lowest-effort of the three media.
    • Shape: Parameterize AlbumsView's data load with a medium filter rather than forking a component. /cuts = AlbumsView with Medium == Cut.
    • Acceptance criteria: /cuts shows only Cut releases with the current AlbumsView ergonomics.
    • Resolved: When /cuts lands, the existing /albums route issues a redirect to /cuts. Old URLs keep working; no hard 404.
  • 9.4.C — SESSIONS (/sessions + /sessions/{id}).
    • What: Gallery of session cards (cover, session name, artist) at /sessions; detail at /sessions/{id} mirroring TrackDetail but with the hero image dominant above the fold, cover secondary.
    • Why: Sessions are an authored content kind the home page advertises; the hero image is their distinctive visual.
    • Shape: Gallery borrows AlbumsView's card-gallery skeleton with a session card face. Detail composes a shared ReleaseDetailScaffold (extracted common metadata + play + player wiring) with a hero-image hero slot — see 9.4.D open question.
    • Acceptance criteria: /sessions lists Session releases; /sessions/{id} renders hero-dominant with the play affordance intact.
  • 9.4.D — MIXES (/mixes + /mixes/{id}) + MixWaveformVisualizer.
    • What: Gallery at /mixes; detail at /mixes/{id} whose defining visual is a MixWaveformVisualizer component fed by the preprocessed waveform datum from MixMetadata, rendered as the full-page background of the detail page. The visualizer is a named, reusable component.
    • Why: Mixes are long continuous sets; the waveform is their signature visual and the brief calls for a reusable visualizer.
    • Shape: MixWaveformVisualizer takes the waveform datum (via WaveformEntryKey → content endpoint) + optional playback-position binding; renders a high-resolution, sophisticated full-page background visual in its own visual language — explicitly not the SpectrumVisualizer / LevelMeterFab peak-bar idiom, which is reserved for the player bar. The two are siblings in subject matter (waveforms) with entirely separate design treatments; they share a data pipeline (9.2.B), never a look. Detail composes the same ReleaseDetailScaffold, with the visualizer as the page-background layer.
    • Acceptance criteria: /mixes lists Mix releases; /mixes/{id} renders the waveform visualizer as the page background fed by real datum (seedable via the 9.2.B trigger, no CMS required); the visualizer is a standalone reusable component visually distinct from the player-bar idiom.
    • Open question: Design the visualizer's seek-on-click position-binding seam now even if click-to-seek ships later? Recommend yes — design the seam, defer the feature (Design for adaptability up front).
  • Prerequisite: 9.2 (the api/release read family). Independent of Wave 3 for both build and acceptance — the body-less 9.2.B waveform trigger seeds real Mix datum and a script can seed hero images, with no CMS in existence.
  • Open questions:
    • Detail-page strategy. Three separate detail pages vs. one branching TrackDetail vs. a shared ReleaseDetailScaffold + per-medium hero slot. Recommend the scaffold (DRY-by-composition, the Phase 8 BatchUpload/BatchEdit extraction move; honours One source, multiple views). Sets the shape of 9.4.C and 9.4.D. Scaffold contract (spec §5.3): it owns exactly the invariant trio — metadata block, play affordance, player wiring; all per-medium variance rides slots (a boolean layout parameter on the scaffold is a design failure). TrackDetail is refactored onto the scaffold in this wave (it is the extraction source — nearly free); if deferred, record the fork as deliberate debt with a retirement note.

Completion note: ARCHIVE nav item implemented in DeepDrftMenu.razor with optional Children collection support in the page model for desktop popover/mobile dropdown. /archive overview page renders three medium cards (reusing §8.6 card design). /cuts route added, parameterizing AlbumsView with medium filter; /albums redirects to /cuts. /sessions gallery and /sessions/{id} detail pages implemented with hero-image-dominant layout; detail composes shared ReleaseDetailScaffold. /mixes gallery and /mixes/{id} detail pages implemented; detail features MixWaveformVisualizer full-page background component rendering waveform from MixMetadata.WaveformEntryKey. ReleaseDetailScaffold extracted from TrackDetail carrying invariant metadata + play + player wiring; TrackDetail refactored to use scaffold. ReleaseClient HTTP service and ReleaseClientDataService implemented alongside ReleaseProxyController in DeepDrftPublic. Waveform visualizer click-to-seek position binding seam designed (inert, feature shipping later). All acceptance criteria met; Wave 4 completes Phase 9 on the public site.


9.5 Wave 5 — Gap Cleanup

Landed: 2026-06-13 on dev.

Waves 14 are on dev. This wave fixes functional gaps discovered in the landed code: one disclosed by the Wave 3 engineer (medium is never written through the upload path), two structural issues flagged by review (fragile track resolution in the detail VM, browser duplication), and one nav gap (/tracks is unreachable from the public menu). Items are ordered: AB are blockers (data correctness); CE are correctness/nav gaps; F is a structural debt item worth landing when the browsers next need editing.

9.5.A — Medium write path: POST api/track/upload

  • What: The POST api/track/upload endpoint accepts no medium form field. CmsTrackService.UploadTrackAsync already sends medium in the multipart body (a forward-compatible no-op left by the Wave 3 engineer), but the API ignores it. Every uploaded release is created with Medium = Cut regardless of the CMS form selection. Sessions and Mixes uploaded through the CMS are silently mis-typed at the database level.
  • Why: This is the primary functional gap of the phase. A mix uploaded as Cut does not appear in the /mixes browser, does not trigger waveform generation on the correct release, and the public /mixes/{id} detail page will never find it. The bug is silent — no error surfaces; the track uploads cleanly into the wrong category.
  • Shape: Three layers, each minimal:
    1. TrackController.UploadTrack — add [FromForm] string? medium parameter. Parse it with Enum.TryParse<ReleaseMedium> (same defensive pattern as releaseType, defaulting to Cut with a logged warning on unrecognised values). Pass the parsed value into UnifiedTrackService.UploadAsync.
    2. UnifiedTrackService.UploadAsync — add ReleaseMedium medium parameter. Include it in the ReleaseDto passed to FindOrCreateRelease (the DTO already has the Medium field; it is simply not populated today).
    3. FindOrCreateRelease find-path: When the release already exists, the returned row's Medium is not updated to match the upload's intent. This is correct behaviour for the first track — the release was created with the right medium. It is potentially wrong for subsequent tracks uploaded to the same release with a corrected medium. No change required here: medium is a release-level property, and the first upload is authoritative. Document this explicitly in the service comment so future engineers do not try to "fix" it.
  • Acceptance criteria: A Session upload from the CMS creates (or links to) a release with Medium == Session; a Mix upload creates a release with Medium == Mix; a Cut upload is unchanged. The GET api/release?medium=session endpoint returns the Session release immediately after upload with no manual migration.
  • Open question: Should the upload path update an existing release's medium when it differs? Recommend no — a release's medium is set on creation and should not silently change on a subsequent track add. If an admin needs to change a release's medium, that is an edit operation (9.5.B). Capture this as a comment in the service, not a policy decision to re-open here.

9.5.B — Medium write path: PUT api/track/meta

  • What: UpdateTrackMetadataRequest carries no Medium field. PUT api/track/meta/{id} can update ReleaseType on a release but cannot change Medium. CmsTrackService.UpdateAsync sends no medium field. An admin who uploads a Session as Cut (due to the pre-9.5.A bug, or a future form mistake) has no way to correct the medium through the CMS after the fact.
  • Why: Without an edit path, the only remediation is a direct DB update or a delete-and-re-upload. Both are bad. The edit path should be complete.
  • Shape:
    1. UpdateTrackMetadataRequest — add ReleaseMedium? Medium (nullable: null = no change, matching the ReleaseType? pattern already on the request).
    2. TrackController.UpdateMeta — apply request.Medium to release.Medium when non-null, alongside the existing ReleaseType conditional (the same six-line pattern at line 394395 of the controller).
    3. CmsTrackService.UpdateAsync — add ReleaseMedium? medium = null parameter, include in the JSON body.
    4. ICmsTrackService — update the interface signature to match.
    5. TrackEdit.razor / BatchEdit.razor — wire the MediumFields selector (already present for upload via BatchUpload) into the edit submit path, passing the selected medium.
  • Acceptance criteria: An admin can open an existing release in TrackEdit or BatchEdit, change the medium selector, submit, and the release's Medium column updates in the DB. The browsers (CmsAlbumBrowser, CmsSessionBrowser, CmsMixBrowser) reflect the new medium after the edit.
  • Constraint: The ReleaseType-only-for-Cut invariant: when medium changes away from Cut, the controller should null (or ignore) ReleaseType on the release — the same enforcement the TrackConverter already applies on the read path. Mirror that logic on the write path: if request.Medium is non-null and not Cut, reset release.ReleaseType = ReleaseType.Single (the DB-level default) rather than leaving a stale studio-format value.

9.5.C — ReleaseDetailViewModel: replace fragile album-title track resolution

  • What: ReleaseDetailViewModel.Load resolves the playable track for a Session or Mix detail page by calling _trackData.GetPage(pageNumber: 1, pageSize: 1, album: release.Title). This is a string join on album title. If two releases share the same title (different artists — e.g., both have an untitled mix), the wrong track is returned. More fundamentally, filtering by album title relies on the Release.Title matching what was stored as the album string at upload time — a join that is fragile once releases can be renamed via the edit path (9.5.B).
  • Why: The correct join is by releaseId, not album title. The track-page endpoint already supports album= filtering; it needs an additional releaseId= filter, or the public API needs a GET api/track/by-release/{releaseId} endpoint. This is a correctness issue, not a cosmetic one — a collision silently plays the wrong track.
  • Shape (recommended): Add a releaseId query parameter to GET api/track/page in TrackController and thread it through ITrackService.GetPagedTrackRepository.GetPagedFilteredAsync as an additional WHERE release_id = @releaseId predicate. TrackFilter gains a long? ReleaseId field. ReleaseDetailViewModel.Load then calls GetPage(pageNumber: 1, pageSize: 1, releaseId: release.Id) — an exact join, no title string. The public IReleaseDataService and ReleaseClientDataService do not need changes if the track page is called directly via ITrackDataService.
  • Acceptance criteria: /sessions/{id} and /mixes/{id} resolve their playable track by releaseId, not by album title string. Two releases with identical titles return their own correct tracks on their respective detail pages.
  • Open question: Should TrackFilter.ReleaseId be exposed on the public unauthenticated GET api/track/page endpoint? Yes — it is a read-only filter on public data, same posture as album= and genre=. No auth change.

9.5.D — Public nav: /tracks route unreachable

  • What: Pages.MenuPages (the public nav model) contains ARCHIVE (with sub-items /cuts, /sessions, /mixes) and Genres. /tracks (the original track gallery at TracksView.razor) is not in the nav. The route is still live — typing /tracks in the address bar works — but there is no menu entry, no link from any existing page, and no redirect from any of the new medium surfaces.
  • Why it matters: The track gallery is a useful surface (flat cross-medium search, grid/list toggle, genre/album filter). Removing it from the nav without a replacement or deliberate deprecation is a nav gap. A listener who does not know about /cuts has no way to discover the flat track list.
  • Shape (three options — pick one):
    • Option A (recommended): Add /tracks back to the nav. Add a "Tracks" entry (flat, no children) to Pages.MenuPages alongside ARCHIVE and Genres. Zero risk; the page exists and works. Honest about what the site offers.
    • Option B: Retire /tracks explicitly. Add a redirect from /tracks/cuts (or /archive) and remove TracksView.razor. Requires confirming that /cuts is a complete replacement (it is not — /cuts shows only Cut releases; /tracks is a flat cross-medium list). Not recommended unless Daniel confirms the gallery is intentionally retired.
    • Option C: Make ARCHIVE the gallery. Repurpose /archive from the current three-card overview to the flat track gallery. Feels wrong — /archive is already a meaningful overview page, not a gallery.
  • Recommendation: Option A. The track gallery is valuable and distinct from the medium-specific browsers. Add "Tracks" to Pages.MenuPages. If Daniel later wants to retire the gallery, that is a separate explicit decision with a redirect. Do not silently leave a useful route off the nav.
  • Acceptance criteria: /tracks appears in the public navigation menu. Desktop and mobile nav both link to it. Existing functionality of TracksView is unchanged.

9.5.E — CmsSessionBrowser and CmsMixBrowser: missing Edit row action

  • What: The Wave 3 spec for 9.3.B says "row Edit + hero-image management" for the Session browser, and the Mix browser should similarly have an edit affordance. The landed CmsSessionBrowser and CmsMixBrowser provide the medium-specific action (hero upload / waveform generate) but no Edit button linking to the standard release edit page (/tracks/album/{name}/edit via BatchEdit).
  • Why: Without the Edit button, an admin cannot rename a session, change its artist, update its genre, or swap its cover art from the browser. The only path is navigating to /tracks, finding the session track, and editing it from there — which itself is now off the nav (9.5.D).
  • Shape: Add a MudButton (or MudIconButton) per row linking to /tracks/album/@Uri.EscapeDataString(context.Release.Title)/edit in both browsers, matching the CmsAlbumBrowser pattern. No new components or endpoints.
  • Acceptance criteria: Each row in CmsSessionBrowser and CmsMixBrowser has an Edit button that navigates to BatchEdit for that release. The edit page loads the release's tracks and release-level fields correctly.

9.5.F — CmsSessionBrowser / CmsMixBrowser structural duplication (DRY debt)

  • What: Both browsers share an identical structural skeleton: a LoadAsync method with _loading / _rows fields, an OnInitializedAsyncLoadAsync call, a ThumbUrl static helper, snackbar error handling, and a MudTable with cover-thumbnail + title + artist columns. Only the per-row action column and the row model differ. This is copy-paste, not composition. The Phase 9 intro promises "a new medium is one entry, one file" — with this structure, a new medium browser is instead two files of boilerplate plus one file of new logic.
  • Why: Manageable now at three media, but violates the open/closed discipline the phase established. The right fix is a MediumBrowserBase abstract base (or a parameterized CmsMediumBrowser component with an action-column slot), reducing each browser to its medium-specific action markup only.
  • Shape: Extract a CmsMediumBrowserBase class (analogous to MediumBrowseBase on the public site) carrying: _loading, _rows, OnInitializedAsync, LoadAsync, ThumbUrl. Subclasses supply the ReleaseMedium and the per-row action column. The table structure (cover, title, artist, actions) is rendered in the base or via a shared CmsMediumTable Razor component with an ActionContent RenderFragment parameter. A new medium browser is then a subclass that overrides the medium enum and implements the action fragment.
  • Acceptance criteria: CmsSessionBrowser and CmsMixBrowser no longer duplicate LoadAsync / ThumbUrl / the error-snackbar pattern. A third medium browser (hypothetical) would require only the medium-specific action markup, with zero structural boilerplate.
  • Note: This is structural debt, not a functional gap. Mark [nice-to-have] if Wave 5 is time-boxed. The functional items (AE) are the priority; F can defer to Wave 6 if needed.

Dependency summary for Wave 5: A and B are independent of each other (parallel tracks) and are the highest priority — both are data-correctness blockers for Session/Mix releases created since Wave 3 landed. C depends on A and B being stable (so the detail VM resolves tracks for correctly-typed releases). D and E are independent nav/UI fixes. F is independent structural debt.

Completion note: POST api/track/upload endpoint extended with [FromForm] string? medium parameter, defensively parsed via Enum.TryParse<ReleaseMedium> and threaded through UnifiedTrackService.UploadAsyncReleaseDto. Release finds are unchanged — existing releases never get their medium updated on subsequent track adds. PUT api/track/meta endpoint and UpdateTrackMetadataRequest extended with ReleaseMedium? Medium field; controller applies non-null values to release.Medium alongside ReleaseType conditional logic, resetting ReleaseType to Single for non-Cut media. CmsTrackService.UpdateAsync signature updated to accept ReleaseMedium? medium parameter and include it in the JSON body. ICmsTrackService interface updated. TrackEdit.razor and BatchEdit.razor now wire the MediumFields selector into the edit submit path. ReleaseDetailViewModel.Load updated to call GetPage(releaseId: release.Id) instead of the fragile album-title string join. TrackFilter extended with long? ReleaseId field. GET api/track/page endpoint now accepts releaseId query parameter and threads it through repository/service layers as an additional WHERE predicate. Two releases with identical titles now return their own correct tracks on detail pages. Pages.MenuPages updated to add "Tracks" entry to public nav alongside ARCHIVE and Genres (Option A). /tracks is now reachable from the public menu. CmsSessionBrowser and CmsMixBrowser each gain a per-row Edit MudIconButton linking to BatchEdit with URI-escaped release title. CmsMediumBrowserBase abstract base extracted carrying _loading, _rows, OnInitializedAsync, LoadAsync, ThumbUrl infrastructure. Shared CmsMediumTable Razor component implemented with ActionContent RenderFragment slot for per-medium actions. CmsSessionBrowser and CmsAlbumBrowser refactored to inherit from base and supply medium-specific action markup. New medium browser requires only action fragment implementation, zero structural duplication. All acceptance criteria met; Wave 5 completes Phase 9 Wave cleanup across API, public site, and CMS.


9.3 Wave 3 — CMS: Release Archive tab, medium selector, medium browsers

Landed: 2026-06-13 on dev.

  • 9.3.A — Release Archive tab + medium selector.
    • What: Rename TrackList.razor's third tab Genre → Release Archive. Inside it, render a medium card group (one card per ReleaseMedium, styled like the existing CmsGenreBrowser cards) where each card navigates to a medium-specific browser. Add a ReleaseMedium selector to TrackNew / TrackEdit / BatchUpload / BatchEdit / AlbumHeaderFields; show ReleaseType only when Medium == Cut, hide it (and surface medium-specific fields) for Session/Mix.
    • Why: The CMS needs to author medium per release and browse the archive by medium. The card-group-of-media is the CMS analogue of the home page's three-medium block.
    • Shape: Cards driven by Enum.GetValues<ReleaseMedium>() + a display-metadata lookup (label/descriptor/swatch) — no hardcoded card switch. Cut card → CmsAlbumBrowser (reused, with a MediumFilter); Session card → CmsSessionBrowser; Mix card → CmsMixBrowser. Selector-driven conditional fields ride per-medium section components (CutFields / SessionFields / MixFields — plain explicit markup inside, no clever generics) behind a single dispatch point (a MediumFields component holding the one @switch) embedded by all five forms — one dispatch, not five scattered conditional blocks. A new medium is one section component + one dispatch entry.
    • Acceptance criteria: The third tab reads "Release Archive" and shows one card per medium; each card navigates to its browser; the upload/edit forms show ReleaseType only for Cut.
  • 9.3.B — CmsSessionBrowser + hero-image authoring.
    • What: New CmsSessionBrowser.razor — a flat list of Session releases (Medium == Session) with cover + hero thumbnail, session name, artist; row Edit + hero-image management. Wire the Session upload/edit path to the hero-image upload endpoint (9.2.B).
    • Why: Sessions are single-track releases with a distinct hero image; the album parent/child expansion of CmsAlbumBrowser is the wrong shape for them.
    • Shape: Reuse CmsTrackGrid parameterized by MediumFilter where the layout fits; the hero thumbnail is an additive column / thin wrapper, not a forked table. Hero upload reuses the cover-art one-shot pattern against HeroImageEntryKey.
    • Acceptance criteria: Session browser lists only Session releases; uploading a hero image persists it and renders the thumbnail.
  • 9.3.C — CmsMixBrowser + waveform trigger wiring.
    • What: New CmsMixBrowser.razor — a flat list of Mix releases (Medium == Mix) with an in-grid waveform-generation status column (mirroring Phase 8's HasWaveformProfile idiom) and a per-row Generate Waveform action. Wire the Mix upload to call the server-side waveform trigger (9.2.B) — the CMS never computes or carries the datum.
    • Why: A Mix without a generated high-res waveform is incomplete; status-in-grid + generate-action is the Phase 8-established pattern for waveform readiness. The CMS has no in-process data layer by convention, so all it does is fire the trigger.
    • Shape: Upload flow: UploadTrackAsyncPOST api/release/{id}/mix/waveform (body-less; the API computes and stores server-side, 9.2.B). The per-row Generate action is the same trigger — recovery costs one POST, with no download/recompute/re-upload of the catalogue's longest audio files.
    • Acceptance criteria: Mix browser lists only Mix releases and shows per-row waveform status; uploading a Mix fires the trigger and the stored high-res waveform appears as generated; the per-row Generate action recovers a missing waveform.
  • Prerequisite: 9.2.
  • Open questions:
    • Genre browse fate. Resolved: the Genre tab slot is taken by Release Archive (Wave 3A as specced); the existing genre browse functionality is deprioritized and stays route-reachable as-is — no active development, no retirement. The team should not remove it.
    • Waveform preprocessor reuse. Resolved: one server-side parameterized pipeline (player-bar peek = low-res, Mix = high-res; One source, multiple views). The WaveformProfileService resolution-parameter refactor lands in Wave 2 with the trigger endpoint (9.2.B), not in this wave.
    • Single-track invariant. Resolved: hard constraint. One track per Session/Mix release is enforced at upload — the CMS form for those media drops the multi-track master list entirely.

Completion note: Genre tab in TrackList.razor renamed to Release Archive; medium card group (Cut / Session / Mix) implemented with enum-driven dispatch to medium-specific browsers (no hardcoded switches). ReleaseArchiveBrowser component renders three cards navigating to CmsAlbumBrowser, CmsSessionBrowser, CmsMixBrowser. MediumFields single-dispatch component added with per-medium field groups (CutFields, SessionFields, MixFields) embedded by TrackNew, TrackEdit, BatchUpload, BatchEdit, AlbumHeaderFields; ReleaseType visible only for Cut medium. CmsSessionBrowser implemented as flat list of Session releases with cover + hero thumbnail columns; hero-image upload via POST api/release/{id}/session/hero-image integrated into upload/edit path. CmsMixBrowser implemented as flat list of Mix releases with in-grid waveform status column (mirroring Phase 8's HasWaveformProfile pattern) and per-row Generate trigger via POST api/release/{id}/mix/waveform. Single-track invariant enforced in BatchUpload for Session/Mix. Known gap: medium write path (medium field in POST api/track/upload and PUT api/track/meta requests) not yet implemented — to be spec'd as Phase 9 Wave 5. All acceptance criteria met; Wave 3 completes Phase 9 on the CMS.


9.2 Wave 2 — API: medium reads + metadata uploads

Landed: 2026-06-12 on dev.

A new api/release controller — the medium unit is the release, not the track, so medium browse and metadata uploads are release-cardinal rather than bolted onto api/track/page.

  • 9.2.A — Release read endpoints (data layer + controller).
    • What: GET api/release?medium={cut|session|mix}&page=&pageSize=&sort= (unauth, paginated, medium filter additive — omitting returns all) and GET api/release/{id} (unauth, single release + medium metadata). The list read Includes the matching metadata table via a per-medium projection map; the by-id read always-Includes both metadata navs (two 1:1 unique-FK joins; non-matching media naturally yield nulls — no per-medium branching, no map).
    • Why: The public CUTS/SESSIONS/MIXES surfaces and the CMS browsers all read releases by medium. One cohesive release-read family keeps api/track/page focused on Phase 8's track-list cases.
    • Shape: Repository/service join through the metadata tables only for the relevant medium on list reads; base release reads never touch them. The projection map carries a dual responsibility: per-medium Include selection and the single enforcement point of the medium↔metadata correlation (a metadata DTO is populated iff the medium matches) — which is why it is not inlined in the controller. The honest extensibility guarantee is "one entry, one file," not "zero controller changes." ReleaseDto gains Medium, a nullable ReleaseType? (nulled at the mapping point for non-Cut), and optional nested SessionMetadataDto? / MixMetadataDto? (populated only for the matching medium — mirrors Phase 8's nested-Release choice, not denormalized flat fields).
    • Acceptance criteria: GET api/release?medium=session returns Session releases with hero-image metadata included and no MixMetadata; medium=cut returns Cuts with neither metadata block and a non-null ReleaseType; non-Cut releases serialize ReleaseType: null; pagination + sort parity with api/track/page.
  • 9.2.B — Metadata write endpoints.
    • What: POST api/release/{id}/session/hero-image (ApiKey, multipart — hero image → image vault → set SessionMetadata.HeroImageEntryKey) and POST api/release/{id}/mix/waveform (ApiKey, no request body — a server-side trigger: the API fetches the mix audio from its own vault, computes the high-resolution waveform via WaveformProfileService parameterized by resolution, stores the datum in the vault, sets MixMetadata.WaveformEntryKey). Both routes are resource-addressed — the release id rides the route.
    • Why: The CMS authoring flows (Wave 3 B/C) need write paths for the medium-specific data, and the waveform is a derived datum the server can compute from audio it already owns. Mirroring the existing body-less POST api/track/{trackId}/waveform idiom makes the datum correct by construction (no trusting a client blob) and keeps the CMS free of any in-process data layer (its standing constraint). Splitting these from the track-upload endpoint keeps each endpoint single-responsibility.
    • Shape: Hero-image upload mirrors the existing cover-art UploadImageAsync → image-vault → link pattern, targeting HeroImageEntryKey. The waveform trigger includes the WaveformProfileService refactor: a per-call resolution/profile parameter (today fixed via injected WaveformProfileOptions.BucketCount = 512) plus a distinct entry-key/vault target for the high-res datum — one pipeline, two resolutions (One source, multiple views). Both endpoints find-or-create the metadata row for the release.
    • Acceptance criteria: Posting a hero image to a Session release sets HeroImageEntryKey and the image is served back through the existing image proxy; the body-less waveform trigger on a Mix release computes + stores a high-res datum, sets WaveformEntryKey, and the datum is retrievable.

Completion note: Five new endpoints on ReleaseController implemented and integrated. ReleaseRepository + ReleaseManager (IReleaseService) in DeepDrftData provide paged medium-filtered reads and satellite metadata writes. UnifiedReleaseService orchestrates vault + SQL operations in DeepDrftAPI/Services/. ReleaseDto updated with Medium field and nested SessionMetadataDto? / MixMetadataDto? properties. Per-medium projection map enforces medium↔metadata correlation at the single mapping point. WaveformProfileService refactored with optional bucketCount? and vaultName? parameters supporting multiple resolutions. VaultConstants.MixWaveforms = "mix-waveforms" added. Five endpoints serve reads (GET api/release with medium filtering and pagination, GET api/release/{id} with both metadata tables included) and writes (POST api/release/{id}/session/hero-image, POST api/release/{id}/mix/waveform). All acceptance criteria met; Wave 3 (CMS) now unblocked.


9.1 Wave 1 — Data model + migration

Landed: 2026-06-12 on dev.

  • What: New ReleaseMedium enum (Cut, Session, Mix) in DeepDrftModels/Enums/. ReleaseEntity gains ReleaseMedium Medium (default Cut) plus 1:1 nav properties to two new metadata entities. New SessionMetadata (HeroImageEntryKey) and MixMetadata (WaveformEntryKey) entities, each 1:1 with ReleaseEntity. EF configurations + migration.
  • Why: Every other wave reads this schema. The discriminator-plus-optional-table shape is the load-bearing decision of the phase; it must land first and land right.
  • Shape:
    • ReleaseMedium enum with Cut = 0 (default — existing/migrated releases stay studio cuts with no discriminator data migration).
    • Medium column on releases; ReleaseConfiguration documents the ReleaseType-only-for-Cut invariant and the named CutMetadata-rejected exception (see the phase intro above).
    • session_metadata and mix_metadata tables, each with a unique FK to releases (1:1). MixMetadata.WaveformEntryKey is a vault entry key (resolved — see open question), not an inline blob.
    • Migration is additive only — no data migration of existing rows beyond defaulting Medium = Cut. Lower risk than the Phase 8 normalization.
  • Prerequisite: Phase 8 §8.0 normalization (ReleaseEntity exists) — already landed.
  • Acceptance criteria:
    • ReleaseMedium enum exists; ReleaseEntity.Medium defaults to Cut.
    • SessionMetadata / MixMetadata entities + EF configs + migration applied; solution compiles and existing releases read back as Cut.
    • The invariant is documented in ReleaseConfiguration (no DB constraint — a deliberate choice; EF supports check constraints, see the phase intro).
  • Open questions:
    • Resolved — waveform storage: vault blob + WaveformEntryKey. Settled by the server-side trigger design (9.2.B): the API computes and stores the datum vault-side; SQL holds only the entry key, so a JSON column never enters the flow. This wave adds only the SQL column — the vault write rides the existing vault abstraction server-side.

Completion note: ReleaseMedium enum with Cut, Session, Mix values implemented in DeepDrftModels/Enums/. ReleaseEntity extended with Medium column (default Cut) and 1:1 nav properties to SessionMetadata and MixMetadata. New entities added with their EF configurations. Additive migration AddReleaseMedium authored and applied. ReleaseDto updated with Medium field and nested metadata DTOs. TrackConverter updated. Solution builds; existing releases read back as Cut; acceptance criteria met.


Phase 8 — CMS Track Browser

8.6 "Music through Every Medium" home page section

Landed: 2026-06-12 on dev.

Replaces the "Genres & Moods" block in DeepDrftPublic.Client/Pages/Home.razor (lines 4386 — the <section class="section"> containing the .genre-grid). The 6 text-only genre cards become 3 image-first cards keyed on release format: Studio, Live, DJ Mix. The pivot is taxonomy → medium: instead of "what scene is this," the section answers "in what form does the music reach you."

The section-divider tag stays "The Sound." The .section-divider and .section-header-grid wrappers are untouched — only the header copy inside the grid and the card grid below it change. Everything from .section-dark onward is untouched.

Design intent. The current section is a flat, typographic palette grid — appropriate when the message was "we span many genres." The new message is fewer, weightier, photographic: three distinct ways the collective produces, each earning a full image pane. This trades the dense 6-up rhythm for a confident 3-up editorial spread, closer in spirit to the dark .features-grid (icon + title + desc) but image-led rather than icon-led. The card is the unit of interest now, not the grid texture.

1. Section header copy

Slot Class Copy
Label .section-label Format & Medium
Title .section-title Music through<br /><em>Every</em><br />Medium
Body .section-body The same hands, three different rooms. A studio cut is built; a live set is risked; a DJ mix is woven. We release in every form the music asks for &mdash; each one a different relationship between the moment and the record of it.

The <em>Every</em> carries the italic-green emphasis the existing .section-title em rule already styles — no change needed there. (Title echoes the prior "Every Frequency Explored" cadence deliberately, so the replacement reads as an evolution of the same voice, not a rewrite.)

2. Card copy

Card Type label (.medium-type, mono) Title (.medium-name, serif) One-line descriptor (.medium-desc)
Studio Studio Studio Releases Composed, layered, and finished &mdash; tracks built to be returned to.
Live Live Live Releases Performances caught in the moment, unrepeatable and unedited.
DJ Mix Mix DJ Mix Releases Uninterrupted sets &mdash; one track bleeding into the next, start to finish.

The type labels (Studio / Live / Mix) play the same one-word-essence role the genre .genre-count labels did ("Foundation," "Architecture," …) — kept deliberately to preserve that tic of the original design.

3. HTML structure sketch

Replaces Home.razor lines 4386. Header grid block keeps its existing structure with only the copy swapped; the grid below is new:

@* Medium section *@
<section class="section">
    <div class="section-header-grid">
        <MudGrid Style="margin-bottom: 5rem;">
            <MudItem xs="12" md="4">
                <div class="section-label">Format &amp; Medium</div>
                <h2 class="section-title">Music through<br /><em>Every</em><br />Medium</h2>
            </MudItem>
            <MudItem xs="12" md="8">
                <p class="section-body"> ...body copy from §1... </p>
            </MudItem>
        </MudGrid>
    </div>

    <div class="medium-grid">
        @* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@
        <div class="medium-card">
            <div class="medium-image" style="background-image: url('img/dd-studio.jpg');">
                <div class="medium-scrim"></div>
            </div>
            <div class="medium-body">
                <div class="medium-type">Studio</div>
                <div class="medium-name">Studio Releases</div>
                <div class="medium-desc">Composed, layered, and finished &mdash; tracks built to be returned to.</div>
            </div>
        </div>
        @* …Live card (dd-live.jpeg) and DJ Mix card (dd-dj.jpeg) follow the same shape… *@
    </div>
</section>

Notes for the implementer:

  • Image as CSS background-image, not <img>. This makes cover-cropping, the scrim overlay, and the hover scale trivial without a wrapper-overflow dance, and keeps these decorative-but-branded photos out of the document's content image flow. (If alt-text/SEO is later wanted, revisit — but these are mood images, not informational, so background is the right call here.) The card is one block: image pane on top, text body below, matching the brief's "image area + text below."
  • The three cards are structurally identical — implementer can author one and repeat. Leave the TODO comment so the future format-filter routing has a home (mirrors the existing @* TODO Phase 2.2 *@ convention in the current genre grid).
  • Whether the card is a <div> or an <a> is deferred: there is no format-filtered route yet (the genre grid had the same unresolved /genres/{slug} TODO). Author as <div> now; the .medium-card hover styles already assume cursor affordance so promoting to <a> later is a one-line change.

4. CSS additions (Home.razor.css)

Added a new block after the (now-removed) genre-grid rules. New classes:

/* ── MEDIUM GRID (Music through Every Medium) ── */
.medium-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1px;
    background: var(--deepdrft-border);
    border: 1px solid var(--deepdrft-border);
    margin-bottom: 4rem;
}

.medium-card {
    background: var(--deepdrft-white);   /* fixed white ground — matches .section, see §9 */
    cursor: pointer;
    overflow: hidden;        /* clips the hover image scale */
    text-decoration: none;
    display: block;
}

.medium-image {
    position: relative;
    width: 100%;
    aspect-ratio: 4 / 3;     /* consistent crop across all three; ~240px tall at 1-col, scales with column width */
    background-size: cover;
    background-position: center;
    transition: transform 0.5s ease;
}

.medium-card:hover .medium-image { transform: scale(1.05); }

.medium-scrim {
    position: absolute;
    inset: 0;
    background: linear-gradient(to bottom,
        rgba(17, 35, 56, 0.0) 40%,
        rgba(17, 35, 56, 0.35) 100%);  /* navy scrim, weighted to the lower edge near the text seam */
    transition: opacity 0.3s;
    opacity: 0.7;
}

.medium-card:hover .medium-scrim { opacity: 1; }

.medium-body {
    padding: 2rem 1.5rem;
    position: relative;
}

/* Green underline sweep — same mechanic as the old .genre-card::after */
.medium-card::after {
    content: '';
    position: absolute;
    bottom: 0; left: 0; right: 0;
    height: 2px;
    background: var(--deepdrft-green-accent);
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 0.3s;
    z-index: 1;
}

.medium-card:hover::after { transform: scaleX(1); }

.medium-type {
    font-family: var(--deepdrft-font-mono);
    font-size: 0.58rem;
    letter-spacing: 0.2em;
    color: var(--deepdrft-muted);
    text-transform: uppercase;
    margin-bottom: 0.6rem;
}

.medium-name {
    font-family: var(--deepdrft-font-display);
    font-size: 1.6rem;
    font-weight: 400;
    color: var(--deepdrft-navy);
    margin-bottom: 0.75rem;
    line-height: 1.1;
}

.medium-desc {
    font-family: var(--deepdrft-font-body);
    font-size: 0.82rem;
    line-height: 1.65;
    color: var(--deepdrft-navy);
    opacity: 0.6;
}

Reuse decisions:

  • .section, .section-divider, .section-header-grid, .section-label, .section-title, .section-body — all reused unchanged.
  • .medium-type / .medium-name / .medium-desc are new but are deliberate near-clones of .genre-count / .genre-name (bumped from 1.5→1.6rem to suit the larger card) / a new descriptor line the genre cards never had. Kept as distinct classes rather than reusing the .genre-* names so the dead genre CSS can be removed cleanly.
  • The underline-sweep ::after is copied from .genre-card::after verbatim except for the added z-index: 1 (needed because the card now has a stacking context from the image).

5. Responsive breakpoints

Viewport .medium-grid columns Behaviour
≥ 960px repeat(3, 1fr) Three cards in a row — the primary editorial layout.
600959px repeat(2, 1fr) + third card spans both columns Two on top, the third full-width below. Reads better than a lone 1-col orphan on tablet and keeps the image panes generous.
< 600px 1fr Single column, cards stack. Each image pane is full content-width; aspect-ratio: 4/3 keeps them generous (~260px tall at a typical mobile width).
@media (max-width: 959px) {
    .medium-grid { grid-template-columns: repeat(2, 1fr); }
    .medium-card:last-child { grid-column: 1 / -1; }     /* third card spans full width */
}

@media (max-width: 599px) {
    .medium-grid { grid-template-columns: 1fr; }
    .medium-card:last-child { grid-column: auto; }       /* reset the span at 1-col */
}

Note the breakpoint boundary is 959px here (the existing genre grid used 960px for its max-width query; .section-header-grid uses min-width: 960px). Using max-width: 959px avoids the 1px both-rules-fire overlap at exactly 960px. Implementer may keep 960 for consistency with the surrounding file if preferred — the last-child span makes the 960 edge case harmless either way.

6. Image placeholder names

All three in DeepDrftPublic.Client/wwwroot/img/ (same dir as existing hero images), referenced as img/<name> to match the existing Image1="img/..." convention:

  • dd-studio.jpg
  • dd-live.jpeg
  • dd-dj.jpeg

File extensions match existing photos on the page (dd-duo-hero.jpeg, kp-shoulder-bw.jpeg). Recommend source images at least 800px wide (rendered up to ~430px wide at the 3-col desktop layout on a 1440px viewport, so 800px covers 2× displays). Consistent landscape orientation across all three — the 4/3 aspect-ratio crop will center-cover whatever is supplied, but landscape sources avoid heavy cropping.

7. Hover and overlay spec

  • Underline sweep (preserved from genre cards): on :hover, a 2px green-accent bar sweeps in from the left along the card's bottom edge (scaleX(0)→(1), 0.3s). Unchanged mechanic.
  • Image scale (new, additive): on :hover, the background image scales to 1.05 over 0.5s, clipped by the card's overflow: hidden. Slow and subtle — a breath, not a zoom. This is the "parallax-scale" the brief allowed; pure CSS transform, no JS.
  • Scrim (always-on, subtle): a navy gradient (--deepdrft-navy at 0%→35% alpha, top→bottom) sits over the image at opacity: 0.7, deepening to 1.0 on hover. Two jobs: (a) it weights the image toward its lower edge so the transition into the text body feels intentional rather than abrupt, and (b) it future-proofs for overlaying white text on the image if a later iteration wants the title on the photo. Today all text sits in .medium-body below the image, so the scrim is purely tonal — keep it light; it should never read as a dark box. If during implementation the supplied photos are already dark/low-key, dial the base opacity down to 0.4 rather than fighting them.

The hover bundle (underline + scale + scrim-deepen) fires together as one gesture. Don't stagger them.

8. Dark-mode awareness

The raw --deepdrft-white and --deepdrft-navy tokens are literal in both themes — they are not remapped under .deepdrft-theme-dark (verified in deepdrft-tokens.css; only the alias tokens like --deepdrft-surface/--theme-* flip). The existing .section and .genre-card both hardcode background: var(--deepdrft-white), so this whole section is a fixed off-white ground in both light and dark mode today — it does not invert.

The new .medium-card follows that same convention deliberately: white card ground, navy text, in both themes. This keeps Phase 8.6 consistent with its untouched siblings (.section above it stays white; only .section-dark below it is dark). Do not introduce theme-aware surface tokens here — that would make this one section invert while the rest of the white .section stays put, which is a larger and out-of-scope design decision (if Daniel wants the public home page to genuinely respond to dark mode, that is a separate roadmap item spanning every .section, not a Phase 8.6 concern).

  • Images: unaffected by theme — same assets render identically. The navy scrim also reads correctly against the off-white card in both modes.
  • Text & backgrounds: --deepdrft-navy text on --deepdrft-white card in both modes. No .deepdrft-theme-dark overrides needed or wanted for this section.

9. Out of scope / deferred

  • Format-filtered routing. Cards are non-navigating today (no /tracks?format= route exists). The TODO comment marks where it lands. This mirrors the genre grid's never-resolved /genres/{slug} TODO — don't build the route as part of 8.6.
  • A real format field on TrackEntity. "Studio / Live / DJ Mix" is presentational copy here, not yet a data dimension. If these cards are ever to filter real tracks, the entity needs a Format/ReleaseType discriminator — that is Phase 3 (new content kinds) territory, not this cosmetic swap. Flagging so the copy isn't mistaken for an existing capability.

Completion note: "Genres & Moods" genre-card grid on home page replaced with "Music through Every Medium" 3-card section (Studio Releases, Live Releases, DJ Mix Releases), each image-led with background image, scrim overlay, hover scale+underline animations. Dead .genre-* CSS rules removed from Home.razor.css. New .medium-* CSS block added with responsive grid (3 cards at md+, 2-up at sm, single column at xs). Type labels corrected to Studio / Live / Mix (final decision superseding earlier spec). Three images (dd-studio.jpg, dd-live.jpeg, dd-dj.jpeg) added to wwwroot/img/.


8.7 CMS upload cache invalidation + "Releases" label rename (Wave 7)

Landed: 2026-06-12 on dev.

Two small runtime-discovered bug fixes in the Phase 8 CMS track browser:

  1. Upload cache invalidation — After a successful track upload in BatchUpload.razor or TrackNew.razor, the code now injects CmsTrackBrowserViewModel and calls VM.Invalidate() before navigating away. This ensures the Releases tab (in Album and Genre browse modes) always shows fresh data and does not display stale cached lists from before the upload. Fixes the issue where newly uploaded tracks would not appear in album/genre browse until a manual refresh.

  2. "Albums" → "Releases" label rename — The toggle tab label in TrackList.razor and the summary card label in the CMS home page (Index.razor) were renamed from "Albums" to "Releases". This better reflects the actual content — releases encompassing all release types (studio, live, DJ mix), not just albums. Improves terminology consistency with the normalized ReleaseEntity and the Phase 8 UI.

Completion note: BatchUpload.razor and TrackNew.razor updated to invalidate CmsTrackBrowserViewModel cache on successful upload. TrackList.razor toggle tab label and Index.razor card label changed from "Albums" to "Releases" for terminology consistency.


8.6 CMS cache invalidation + orphaned release deletion (Wave 6)

Landed: 2026-06-12 on dev.

Three linked CMS bug fixes discovered during Phase 8 browser work:

  1. Cache invalidation on mutations — Added CmsTrackBrowserViewModel.Invalidate() method called from TrackEdit, BatchEdit, and TrackList.OnAlbumsChanged after any track/release mutation. Ensures the album/genre browse cache is never stale when tracks are added, edited, or deleted.

  2. Orphaned release handlingCmsAlbumBrowser now handles 0-track (orphaned) releases with a confirmation dialog + DeleteReleaseAsync via a new DELETE api/track/release/{id} endpoint. Partial-failure album-delete path also invalidates the cache. Admin can now clean up releases that have lost all their tracks.

  3. Cascade-delete on last-track removal — EF migration SoftDeleteOrphanedReleases (data-only, raw SQL) backfills orphaned release rows with soft-delete markers. UnifiedTrackService.DeleteAsync now cascades a release soft-delete when the last live track in a release is deleted (non-fatal; orphaned releases do not block track deletion).

Completion note: CmsTrackBrowserViewModel.Invalidate() added and wired into mutation paths. New DELETE api/track/release/{id} endpoint implemented on UnifiedTrackService. CmsAlbumBrowser updated with orphaned release confirmation + delete. SoftDeleteOrphanedReleases migration authored and applied. All three fixes integrated; Phase 8 browse modes remain stable with correct cache coherence and release cleanup semantics.


8.0 TrackEntity normalization

Landed: 2026-06-11 on dev.

  • What: Split the flat TrackEntity into two normalized tables. New ReleaseEntity holds release-cardinal data (Title, Artist, Genre?, ReleaseDate?, ImagePath?, ReleaseType, CreatedByUserId?). Slimmed TrackEntity holds track-cardinal data only (Id, ReleaseId FK, Release nav, EntryKey, TrackName, TrackNumber, OriginalFileName?) — the release fields are removed from it. New ReleaseDto; TrackDto slims and gains ReleaseId + a nested Release (ReleaseDto) (resolved 2026-06-11: nested, not a flat read model — flat fields are removed and every consumer is updated, not denormalized back); AlbumSummaryDto is retired in favour of ReleaseDto.
  • Why: The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no GROUP BY-derived summary).
  • Shape: Sequenced as five mergeable waves (notes §0.6): (1) data model — ReleaseEntity/config/migration in DeepDrftData; (2) DTOs/services/repositories/API — ReleaseDto, slimmed TrackDto, JOIN-projecting repository, upload find-or-create Release; Waves 1 + 2 are a single deployment unit (removing the entity fields breaks compile until the DTO/service layer lands — never merge Wave 1 alone); (3) public-client consumers (TrackCard, TrackDetail, TrackMetaLabel, NowPlayingCard) re-point to track.Release.*; (4) existing CMS surfaces (TrackEdit, TrackNew, BatchUpload, TrackList) minimally updated to compile on the normalized model — Waves 3 + 4 run in parallel; (5) the Phase 8 UI (§8.1–§8.5) begins only after 14 are stable. The breaking migration: create releases, populate from distinct (album, artist) groups, add + populate release_id FK, drop redundant track columns. Remaining open questions for Daniel: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes — committed in Wave 2 shape). Full spec, wave breakdown, and per-file consumer list: notes §0 / §0.6.

Completion note: ReleaseEntity table and EF configuration implemented in DeepDrftData. Two EF migrations landed (NormalizeReleaseTrack and AddReleaseUniqueTitleArtist) with full data migration backfill. ReleaseDto and slimmed TrackDto (with nested Release property) implemented in DeepDrftModels. Repository updated with JOIN-projecting queries. API controllers updated to return nested DTOs. Public-client consumers (TrackCard, TrackDetail, NowPlayingCard) and CMS surfaces (TrackEdit, TrackNew, BatchUpload, TrackList) all updated to point to track.Release.* fields. All five waves complete and merged to dev. Build clean, 155 tests pass. §8.1–§8.5 now unblocked.

8.1 URL scheme + mode toggle

Landed: 2026-06-11 on dev.

  • What: /tracks (Track mode, default), /tracks/albums, /tracks/genres as route segments; a toggle inside the existing "Tracks" tab switches mode and pushes the matching URL. The Waveform Pre-Processing tab is untouched.
  • Why: The public home page hard-codes these as cross-host deep-links; a route segment reads as a stable address and matches the app's existing segment-based routing (/tracks/upload, /tracks/{id}). Query-param mode (?mode=) was the alternative — rejected as transient-looking view state, optionally tolerated as an alias.
  • Shape: One TrackList component carrying three @page directives (or three thin wrappers passing an InitialMode); the toggle drives Mode + NavigationManager.NavigateTo. See notes §3, §9.

Completion note: TrackList.razor refactored to support three route modes via @page directives (Track/Album/Genre). Mode-toggle control added to the UI, wired to NavigationManager.NavigateTo to push the matching URL. Toggle persists selection across navigation.

8.2 CmsTrackGrid — the reusable flat track table (DRY core)

Landed: 2026-06-11 on dev.

  • What: Extract today's MudTable<TrackDto> into a standalone CmsTrackGrid.razor taking AlbumFilter/GenreFilter params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (d MMMM, yyyy) → Waveform Status → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public TrackCard fallback pattern, defined locally CMS-side.
  • Why: Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. The Waveform column replaces the removed Waveform Pre-Processing tab (status visible inline; per-row Generate when no profile; page-level "Generate All Missing" in the Track-mode header).
  • Shape: Owns its own MudTable + LoadServerData + delete-confirm (lifted from TrackList). GetPagedAsync gains optional album/genre filter params — the one filter data-contract change (the endpoint already supports the filters); post-§0 the filter joins through releases. Waveform status comes from a new HasWaveformProfile bool on TrackDto (recommended over a second per-page lookup; fold into the §8.0 DTO pass). Display date format is presentation-only; sort key stays the raw DateOnly. See notes §8, §9, §11.

Completion note: New CmsTrackGrid.razor component implemented with full table layout (Track #, art thumb, name, artist, album, genre, release date, waveform status, actions). ICmsTrackService.GetPagedAsync extended with optional album and genre filter parameters. HasWaveformProfile bool added to TrackDto. Waveform status column displays profile state; per-row and page-level Generate actions wired. Info tooltip displays Entry Key and File Name. Grid consumed by Track mode (no filter) and Genre mode (genre filter); single source of truth for table markup.

8.3 Album mode

Landed: 2026-06-11 on dev.

  • What: CmsAlbumBrowser — parent release rows (art, title, artist, track count, genre, release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track.
  • Why: A scannable release catalogue is the CMS analogue of the public AlbumsView, and the natural place to manage a release as a unit.
  • Shape: Post-§0, parent rows are ReleaseEntity/ReleaseDto rows — GetReleasesAsync (eager, once) supplies title/artist/genre/date/type directly, no derivation. Child tracks lazy via GetPagedAsync(album:) (joins through releases) on first expand, cached per row — no new endpoint. Expandable MudTable over MudTreeView (parent rows are multi-column, not tree-shaped). The old AlbumSummaryDto widening question is dissolved by §8.0 normalization — the Release table has all the fields, so the parent row is fully populated at rest with no DTO widening and no lazy derivation. See notes §6, §10, §0.5.

Completion note: New CmsAlbumBrowser.razor component implemented as an expandable release-row browser. Parent rows display ReleaseDto data (art, title, artist, track count, genre, release date, release-type chip). Child tracks loaded lazily on expand via GetPagedAsync(album:), cached per row. Edit action navigates to Batch Edit page; Delete action removes album and all its tracks with confirmation. Leverages normalized ReleaseEntity from §8.0 — Release rows are fully populated at rest, no lazy derivation required.

8.4 Genre mode

Landed: 2026-06-11 on dev.

  • What: CmsGenreBrowser — a responsive MudCard grid (one card per genre: name + track count); clicking a card expands it (accordion, one open at a time) to reveal a CmsTrackGrid filtered to that genre.
  • Why: CMS analogue of the public GenresView; the card-to-grid expand is the cheapest second mode because the grid is already built (§8.2).
  • Shape: GetGenreSummariesAsync once; the expanded panel renders CmsTrackGrid with GenreFilter set and the Add button suppressed — zero duplicated table markup. The embedded grid gets the waveform status column + per-row generate for free. See notes §7, §9.

Completion note: New CmsGenreBrowser.razor component implemented as a responsive card-grid accordion. Each card displays genre name and track count. Clicking a card expands it to reveal CmsTrackGrid filtered to that genre (Add button suppressed). One card open at a time. Grid embedded within each expanded panel inherits waveform status column and per-row generate actions. Zero duplicated table markup — consumes the single CmsTrackGrid source built in §8.2.

8.5 Batch Edit page

Landed: 2026-06-11 on dev.

  • What: New page /tracks/album/{albumName}/edit, reached from an Album-mode row's Edit action. BatchUpload's master-detail mechanics with the release's data preloaded; submit swaps per-row UploadTrackAsync for UpdateAsync on existing tracks (new tracks still upload). Distinct from the existing single-track edit at /tracks/{id}.
  • Why: Editing a release as a unit (rename tracks, reorder, swap cover, add tracks) without round-tripping the single-track editor per track.
  • Shape: Confirmed: a new BatchEdit.razor sharing extracted sub-components with BatchUpload — album-header fields block (post-§0 edits the ReleaseDto), batch track list (move-up/down/remove + status chips), track detail pane — over growing BatchUpload with an isEdit flag (the flag breeds conditional soup across preload/detail/submit). Cover art uses the established upload-once-then-link-via-UpdateAsync two-step. Open: does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8).

Completion note: New BatchEdit.razor page implemented at /tracks/album/{releaseName}/edit. Shares extracted sub-components with BatchUpload: AlbumHeaderFields, BatchTrackList, BatchTrackDetail, BatchRowModel. Two-panel layout with release-header block (album name, artist, genre, release date, cover art, release type) and left queue + right detail sections. Submit path swaps per-row UploadTrackAsync for UpdateAsync on existing tracks; new tracks still upload. Cover art uploaded once, linked via UpdateAsync. Remove-in-edit deletes existing track with confirmation. Reusable sub-components extracted for consistency across BatchUpload and BatchEdit.


Phase 1.2 — Audio format diversity

Landed: 2026-06-11 on dev (all three waves complete).

  • What: Today AudioProcessor, WavOffsetService, and the JS decoder are PCM/WAV-only. MimeTypeExtensions already maps MP3, FLAC, Ogg, AAC, M4A — none are wired.
  • Why it matters: WAV-only is a real ceiling for any non-internal release. Distribution-grade formats (MP3, FLAC at minimum) are table stakes for a music site.
  • Shape: Two seams need a strategy pattern.
    • Server side: replace AudioProcessor.ProcessWavFileAsync with a format-router that selects a per-format processor; replace WavOffsetService with a per-format offset strategy (some formats — MP3, Ogg — have natural frame boundaries; FLAC has block headers; AAC has ADTS).
    • Client side: the JS decoder is currently a WAV byte-walker. For non-WAV, the simplest path is decodeAudioData over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
  • Prerequisite: None functionally, but consider settling Phase 4 (HTTP Range) first — native range/cache is much more important for large MP3s than for WAVs.
  • Constraint: Spectrum FFT tap currently relies on raw AudioBuffers through decodeAudioData. If a future path uses MediaElementAudioSourceNode (see 4.1), the FFT tap still works but the early-playback story changes.

Completion note: Fully landed across three waves on 2026-06-11. Server upload now accepts .wav/.mp3/.flac via AudioProcessorRouter. Client StreamDecoder is format-agnostic; Mp3FormatDecoder and FlacFormatDecoder provide chunked streaming with frame-boundary alignment and seek. Factory routing in AudioPlayer.createFormatDecoder selects decoder by Content-Type.


Phase 7 — Shared UI Components

7.1 ParallaxImage component

Landed: 2026-06-11 on dev.

  • What: A thin viewport-height container that reveals different portions of an image as the user scrolls — the classic CSS parallax window. As the window scrolls up through the viewport, the image pans through it faster than the page scrolls (top of image on entry, bottom of image by the time the window reaches the top of the viewport). An optional second image crossfades in on hover (intended use: grayscale at rest, colour on hover). A critical FullWidth flag stretches the window to 100vw, breaking out of parent padding. Full signature and design in product-notes/parallax-image-component.md.
  • Why it matters: A reusable scroll flourish for hero/section surfaces on both the public site and the CMS, landing the visual identity work without bespoke per-page CSS. It is the first genuinely shared presentational component in DeepDrftShared.Client — establishes the pattern (and the RCL static-asset JS-module seam) for shared UI that both hosts consume.
  • Shape: ParallaxImage.razor (+ .razor.cs, .razor.css) in DeepDrftShared.Client/Components/. Scroll-driven background-position (never background-attachment: fixed — broken on iOS Safari), gated by an IntersectionObserver so off-screen instances cost nothing. Scroll math lives in a small JS module; lifecycle owned by Blazor via ElementReference + an imported IJSObjectReference, mirroring the existing audio interop seam. Crossfade is pure CSS. IAsyncDisposable tears down the listener. Full parameter table, parallax math, interop contract, full-width breakout technique, accessibility (reduced-motion, alt text), and edge cases (mobile Safari, preload timing) are specified in the product note.
  • Prerequisite: None functionally. Additive — no existing surface changes to adopt it.
  • Constraint: Both open decisions resolved (Daniel, 2026-06-11), no blockers remaining — TS toolchain added to the shared RCL with source co-located at DeepDrftShared.Client/Interop/parallax/parallax.tswwwroot/js/parallax/parallax.js, served from _content/DeepDrftShared.Client/… to both hosts; and parallax direction is exposed as the InvertDirection component parameter rather than hardcoded. See product note §6a/§11.1 and §3/§11.2.

Completion note: ParallaxImage.razor + .razor.cs + .razor.css implemented in DeepDrftShared.Client/Components/. TS interop module at DeepDrftShared.Client/Interop/parallax/parallax.ts compiled to wwwroot/js/parallax/parallax.js. Microsoft.TypeScript.MSBuild 5.9.3 added to DeepDrftShared.Client.csproj, matching the pattern in DeepDrftPublic. Component exposes InvertDirection parameter for parallax direction; scroll-offset math and IntersectionObserver lifecycle owned by the TS module via IJSObjectReference interop.


Phase 6 — CMS Enhancements

6.3 Batch Upload Page

Landed: 2026-06-11 on dev.

  • What: Replace the single-track form at /tracks/new with a two-panel batch upload page that uploads many WAVs in one session under a shared album header.
  • Why: Uploading an album one track at a time is the current reality — re-entering album, genre, release date, cover art, and artist on every track. Batch upload makes "add a release" a single operation: set the shared header once, queue the tracks, submit. This is the dominant ingestion shape for the collective (releases, not loose singles).
  • Shape:
    • Route: New page at /tracks/upload. Justification: /tracks/new reads as "new single track" and the edit route is /tracks/{id}; /tracks/upload names the operation (batch ingestion) without colliding with the id-parameterised edit route. Repoint the "Add Track" button in TrackList.razor (currently Href="/tracks/new") to /tracks/upload. Whether /tracks/new is retired or left as a redirect is staff-engineer's call; the committed change is that the button goes to the batch page.
    • Data model change — ReleaseType: Add a ReleaseType enum to DeepDrftModels (enum ReleaseType { Single, EP, Album }). Enum over string: three fixed values, and it gates UI (selector) and future grouping logic — a free-text column invites typos. Add a ReleaseType property to TrackEntity and TrackDto. Decide nullability: recommend non-null with a default of Single so existing rows backfill cleanly to a sensible value (a release of one track is a single) and the column is never null. This ripples to TrackConfiguration (EF mapping — store as string via HasConversion<string>() for readable DB values, or as int; recommend string for legibility), TrackConverter (assign on round-trip), and the upload/update service signatures. An EF migration is required — author it via dotnet ef migrations add, never by hand.
    • Data model change — TrackNumber: Add a TrackNumber property (type int, 1-based, non-null) to TrackEntity and TrackDto to store per-track ordinal position within a release. This ripples through TrackConfiguration (EF mapping) and TrackConverter (assign on round-trip) the same way ReleaseType does. A second EF migration is required — author it via dotnet ef migrations add, never by hand. May be combined into a single migration with the ReleaseType change — staff-engineer's call on whether to combine or keep separate.
    • Shared-vs-per-track field split:
      • Shared (header strip, applied to every track in the batch): album name, artist, album cover image (single upload), genre, release date, and ReleaseType. One album per batch — the entire batch is one release, and all release-level fields live in the header.
      • Per-track (right detail panel): track name, the individual WAV file, and that row's upload status.
    • Layout (two-panel under a header strip):
      • Header strip (full width, top): album name, artist MudTextField, single cover-art InputFile (reuse the MudField cover-art pattern from TrackNew, including the upload-on-submit behaviour), genre MudTextField, release-date field, and ReleaseType MudSelect. These bind to a single batch-header model.
      • Left panel (track queue): an ordered list of queued tracks; the row order is the release track order and reflects each track's TrackNumber. Each row shows track name, a reorder affordance (up/down MudIconButtons are the low-risk choice; drag-and-drop is a nice-to-have — see open questions), a remove button, and a per-row status indicator (queued / uploading / done / failed). A +/InputFile (with multiple) at the top or bottom of the list adds WAV files; each added file becomes a row with track name defaulted from the filename (sans extension). On submit, each track is assigned its TrackNumber (1-based) from its position in the list.
      • Right panel (selected-track detail): when a row is selected, show its editable fields — track name and the WAV file name/size/status. Selecting a different row swaps the detail.
    • Add-files behaviour: InputFile multiple → append a row per file. Default track name = filename without extension. New rows append to the end of the list, taking the next ordinal position. Keep the 1 GB per-file ceiling and the .wav validation from TrackNew.
    • Submit behaviour: Sequential, one request at a time — reuse the existing single-track upload path (CmsTrackService.UploadTrackAsync) in a loop. This mirrors the deliberately-sequential waveform backfill in TrackList.GenerateAllMissing ("one request at a time so a large backfill does not flood the API"). Per-track progress: each left-panel row reflects its state as the loop advances (StateHasChanged between rows). Cover-art upload happens once before the loop (upload the image, get the entry key, then pass/link it to every track) — do not re-upload the cover per track. On completion, snackbar a summary (uploaded N, M failed) and navigate to /tracks. Partial failure: completed tracks stay persisted; failed rows remain visible with their error so the admin can retry just those — do not roll back the batch.
    • CmsTrackService surface: No new method strictly required — the loop calls the existing UploadTrackAsync per track and the existing image upload/link path per batch. UploadTrackAsync's signature gains releaseType and trackNumber parameters (ripples from the data-model change). If the cover-link follow-up (the UpdateAsync step TrackNew does today) is kept per track, that's existing surface too.
    • API surface: No new endpoints. Existing POST api/track/upload (per track) and POST api/image/upload (once per batch) cover it. api/track/upload and the metadata update endpoints gain releaseType and trackNumber in their payloads as a consequence of the entity change.
    • Components: BatchUpload.razor (page + header strip + orchestration), and reasonably a BatchTrackRow model class plus left-panel/right-panel as child components or inline sections — staff-engineer's structural call.
    • Constraint — dual-write orphan risk: Each track inherits the existing dual-write hazard (audio lands in the vault, SQL persist may fail → orphaned audio, no rollback). Batch upload multiplies the exposure (N tracks per session instead of one). The mitigation is Phase 4.3 (dual-write rollback / dead-letter log) — not a blocker for this feature, but this is the strongest argument yet for landing 4.3. Flag it as a known constraint; do not attempt per-batch transactional rollback (the dual-database split can't give it).
  • Prerequisites:
    • ReleaseType enum + TrackNumber field + TrackEntity/TrackDto changes + EF migration(s) must land first (it's the data-model floor for the whole feature, and ripples through TrackConfiguration/TrackConverter/service signatures). Could be a separate prep commit before the page work.
    • Not blocked by Phase 4.3, but 4.3 is the right mitigation for the amplified orphan risk and is worth sequencing alongside.
  • Resolved (no longer open):
    • One album per batch. The whole batch is one release; album name and all release-level fields (artist, genre, release date, ReleaseType, cover art) live in the shared header strip. A batch never mixes albums.
    • Track ordinals are persistentTrackNumber (int, 1-based, non-null) stores per-track position within a release. The left-panel row order reflects TrackNumber, and each track is assigned its ordinal from its list position on submit.

Completion note: BatchUpload.razor page implemented at /tracks/upload; two-panel layout with header strip (shared album/artist/genre/release-date/cover-art/release-type fields) and left queue + right detail sections for per-track track name and file selection. Sequential upload loop via existing CmsTrackService.UploadTrackAsync. Cover-art uploaded once at start; per-track progress reflected in left-panel status indicators. TrackList.razor "Add Track" button repointed to /tracks/upload. ReleaseType enum and TrackNumber int field added to TrackEntity, TrackDto, TrackConfiguration, TrackConverter, and EF migrations authored. UploadTrackAsync signature updated with releaseType and trackNumber parameters.


6.1 CMS Home Page — catalogue summary dashboard

Landed: 2026-06-11 on dev.

  • What: Replace the redirect-to-/tracks at Index.razor (route /) with a real dashboard showing a grid of summary cards: total tracks, distinct albums, distinct genres.
  • Why: Quick orientation for the CMS admin — at-a-glance catalogue health on landing, instead of dropping straight into the table. First thing the admin sees, so it carries the bold DeepDrft palette rather than a conservative admin look.
  • Shape:
    • Route / component: Keep Index.razor at /; remove the OnInitialized redirect and render the dashboard. The CMS nav lands here; /tracks remains reachable from the nav and from the cards.
    • UI: A responsive MudGrid of three MudCards (Tracks / Albums / Genres). Each card: an icon (LibraryMusic, Album, Category or similar), the metric as a large Typo.h2/h3 number, and a label. Cards are clickable (@onclickNav.NavigateTo). Lean into the active MudBlazor palette — Color.Primary/Color.Secondary fills or accent borders, generous elevation — this is the visual-punch surface, not a muted KPI strip. Loading state: skeleton or per-card MudProgressCircular while the three fetches resolve. Each card fetches independently so one slow/failed call doesn't blank the others; a failed card shows a "—" with a retry affordance rather than collapsing the grid.
    • Card navigation (Phase 6 scope): All three cards navigate to /tracks (the track maintenance page). Per-album / per-genre pre-filtering is deferred — see 6.2. Ship the cards as plain links to /tracks now.
    • Data model: No entity changes. AlbumSummaryDto and GenreSummaryDto already exist in DeepDrftModels.
    • API surface: No new API endpoints. The three numbers are already available:
      • Albums count = length of GET api/track/albums (exists, unauthenticated, returns List<AlbumSummaryDto>).
      • Genres count = length of GET api/track/genres (exists, unauthenticated, returns List<GenreSummaryDto>).
      • Tracks count = TotalCount from GET api/track/page (exists) requested with pageSize=1 (cheapest paged call that still returns the total).
    • CmsTrackService surface (new methods): ICmsTrackService does not currently expose albums/genres. Add three thin proxy methods mirroring the existing pattern (e.g. GetAlbumSummariesAsync, GetGenreSummariesAsync, and a GetTrackCountAsync that calls page?pageSize=1 and returns TotalCount). These are the only new code on the service. No controller work.
    • Components: Index.razor (dashboard host) plus, optionally, a small SummaryCard.razor for the repeated card — worth extracting given three near-identical cards, but staff-engineer's call.
  • Prerequisites: None. All backing endpoints and DTOs exist.

Completion note: DeepDrftManager/Components/Pages/Index.razor redesigned as a 3-card dashboard grid (Tracks / Albums / Genres counts) with independent per-card fetches. Three new ICmsTrackService proxy methods (GetAlbumSummariesAsync, GetGenreSummariesAsync, GetTrackCountAsync) wired to existing public API endpoints. Cards navigate to /tracks on click. Failed cards show "—" fallback; each card loads independently.


Phase 1.1 — Extended WAV format support

Status: Fully landed on 2026-06-10 (IEEE Float SubFormat 0x0003 and Padded 24-in-32 container support implemented, tests passing).

  • What: Two EXTENSIBLE WAV sub-cases that were explicitly scoped out of the WAVE_FORMAT_EXTENSIBLE PCM fix (which shipped support for audioFormat=0xFFFE with a PCM SubFormat — the Bandcamp WAV download case). Both are currently rejected at AudioProcessor.ValidateAudioParameters and fall back to default metadata. The inline comments at AudioProcessor.cs (SubFormat check ~L182188, BlockAlign note ~L225230) mark them as accepted gaps as of that fix.
    • EXTENSIBLE non-PCM SubFormats — e.g. IEEE Float (32-bit float PCM, common in DAW exports). The SubFormat-GUID check accepts only PCM (0x0001) today; anything else is rejected outright.
    • Padded-container EXTENSIBLE — 24-bit valid samples in a 32-bit container (wValidBitsPerSample=24, container bitsPerSample=32). The BlockAlign check fails because the valid-bit depth (24) doesn't match the container's block align.
  • Why it matters: DAW exports — the dominant shape of source material as the collective uploads more of its own production — tend to be float WAV or padded 24-bit. The shipped fix covers consumer/Bandcamp WAVs but not the producer's working files.
  • Shape: Both live in the same seam as the shipped fix (AudioProcessor validation + the NormalizeToStandardPcm storage step), but the work differs by case:
    • Float SubFormat: requires float→integer sample conversion during the normalize-to-standard-PCM step (the vault stays integer-PCM so the streaming/decode pipeline is unchanged), or a Web Audio decode path that handles float directly. The conversion-at-storage option keeps the load-bearing streaming seam untouched and is the lower-risk path.
    • Padded 24-in-32: relax ValidateAudioParameters to tolerate the BlockAlign mismatch when IsExtensible, then normalize to the valid-bit depth (24) during storage so the stored WAV is canonical.
  • Prerequisite: None. Both are self-contained extensions of the WAV path that just landed; neither depends on the broader format-router work in 1.2.
  • Relationship to 1.2: Distinct from it. 1.2 is new containers (MP3, FLAC, Ogg) behind a format router; this is additional WAV variants on the existing PCM path. If 1.2's router lands first, these become per-variant branches inside the WAV processor rather than new processors.

Completion note: IEEE Float SubFormat (0x0003) support added via ConvertFloatTo24BitPcm conversion at storage time; Padded 24-in-32 container support added via RepackPaddedContainer with relaxed ValidateAudioParameters BlockAlign check. Both cases tested in 8 new AudioProcessorTests cases. Vault stores standard 24-bit PCM in both cases; streaming/decode pipeline unchanged.


Status: Fully landed on 2026-06-10.

  • What: Free-text search (?q=) across TrackName/Artist/Album via EF.Functions.ILike (Postgres, case-insensitive); album/genre exact-match filtering (?album=, ?genre=); new /albums browsing page (grid of album cards with cover art and track counts, linking to filtered gallery); new /genres browsing page (genre list with counts, linking to filtered gallery); search bar with 400ms debounce and filter-pill dismiss on TracksView. Nav updated with Albums and Genres links.
  • Architecture: Filter is threaded as a separate TrackFilter DTO alongside PagingParameters<T> (which is external and cannot carry a where-clause). Repository has new GetPagedFilteredAsync, GetDistinctAlbumsAsync, GetDistinctGenresAsync methods. PersistentComponentState restore on TracksView is skipped when filter params are active. ClearFilter preserves SearchText (only clears album/genre pill).
  • New types: TrackFilter, AlbumSummaryDto, GenreSummaryDto in DeepDrftModels/DTOs/.
  • Tests: TrackFilterQueryTests in DeepDrftTests — 4 in-memory cases plus 1 Postgres-gated ILike case (skip when DEEPDRFT_TEST_PG env var absent).

Phase 4.1 — HTTP Range + CDN caching

Status: Fully landed on 2026-06-09 (implementation complete, all acceptance criteria met, merged to dev branch p4-w1-range-streaming).

  • What: Today's ?offset= query parameter defeats HTTP caching — a CDN sees ?offset=1234567 as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. Move the player's transport to standard HTTP Range headers against one canonical URL.
  • Why it matters: Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
  • Chosen approach (design pass 2026-06-09): Option A1 — Range headers in the JS fetch, keep the custom AudioBuffer decoder. Rejected Option B (MediaElementAudioSourceNode): it surrenders early-playback (the minBuffersForPlayback start-as-soon-as-buffered behaviour, a listed quality feature) and forces a redesign of the waveform-seek and early-play UX, while delivering no caching benefit beyond what the HTTP layer already gives. Also rejected A2 (synthesised header delivered over Range): keeping WavOffsetService on the hot path means each bytes=X- request produces a distinct synthesised prefix that can't share cache lineage with the canonical bytes=0- object, defeating half the caching win. A1 makes the cached object the real file, so every Range request is a true sub-range of one entity. Key enabling insight: StreamDecoder already synthesises a per-segment 44-byte header internally for every decodeAudioData call (createWavFile), so a Range continuation only needs to retain the parsed WavHeader and feed raw PCM — it does not need a header in the network stream.
  • Shape (implementation direction):
    • Server (DeepDrftAPI/Controllers/TrackController.cs ~L407): flip enableRangeProcessing: false → true on the no-offset seekable FileStream path; ASP.NET Core slices natively and emits 206 + Content-Range. Leave the ?offset= / WavOffsetService branch reachable but off the player hot path — its removal is a clean follow-up commit, not part of this change.
    • Proxy (DeepDrftPublic/Controllers/TrackProxyController.cs ~L175): forward the incoming Range request header upstream; pass through upstream status (206/200/416) and the Content-Range / Accept-Ranges / Content-Length response headers verbatim. The proxy is a transparent relay — it does not slice the (non-seekable) upstream stream. Keep ResponseHeadersRead + RegisterForDispose.
    • Client transport (DeepDrftPublic.Client/Clients/TrackMediaClient): send Range: bytes={byteOffset}- instead of the ?offset= query param (byteOffset == 0bytes=0-, single code path). Confirm TrackMediaResponse.ContentLength carries the 206 remaining-length for continuations and full length for the initial request.
    • JS decoder (StreamDecoder.ts — the real work): add a continuation mode. Replace reinitializeForOffset (which nulls wavHeader and re-parses) with a reinitializeForRangeContinuation(remainingByteLength) that retains the parsed WavHeader, resets rawChunks/totalRawBytes/processedBytes/streamComplete, and routes incoming bytes straight to addRawData (the existing if (!this.wavHeader) branch already does this when the header is set). Add an isContinuation flag so updateStreamCompleteFlag() uses totalRawBytes without the + headerSize addend on continuations. createWavFile, the decode pipeline, and the spectrum/level tap are all unchanged.
    • AudioPlayer.ts / index.ts: keep the public reinitializeFromOffset interop name (so AudioInteropService and the C# caller are untouched); internally call the continuation reinit. C# StreamingAudioPlayerService.SeekBeyondBuffer is otherwise unchanged.
  • Acceptance criteria:
    1. Initial load sends Range: bytes=0-; server responds 206/200 with Accept-Ranges: bytes; time-to-first-audio unchanged (early playback after minBuffersForPlayback).
    2. Seek-beyond-buffer sends Range: bytes=X- (block-aligned, file-absolute X) with no ?offset= anywhere; server responds 206 + Content-Range; audio resumes with no click/pop and no header bytes leaking into PCM.
    3. Displayed total duration is unchanged across a seek (original full-track duration, not remaining-segment).
    4. A track seeked-near-end then played out fires the end callback exactly once (continuation streamComplete math correct).
    5. Spectrum visualiser and LevelMeterFab behave identically pre/post on a loud master (3 dBFS).
    6. Same-URL invariant: two different-offset requests hit an identical URL differing only in the Range header (verifiable in the network panel; live CDN cache-hit verification is out of scope — no CDN in dev).
    7. No MediaElement introduced; the AudioBufferSourceNode graph remains the playback path.
  • Constraints (non-obvious):
    • Range offset is file-absolute, not audio-relative. The old ?offset= contract was audio-data-relative (WavOffsetService added HeaderSize server-side). The Range offset must be header.headerSize + blockAlignedAudioOffset. Omitting headerSize lands the seek ~44 bytes early — audible click + position drift. Most likely bug; verify first.
    • Only the continuation skips header parse; the initial bytes=0- response still flows through tryParseHeader unchanged. Don't let the continuation flag bleed into initial load.
    • Proxy must pass Accept-Ranges / Content-Range (and a 416) through verbatim — stripping them blinds the browser and any future CDN.
    • A1 preserves the multi-format (1.2) seam: the decoder stays the format integration point; the "retain format, skip header, treat bytes as frame data" pattern generalises (frame-boundary alignment differs per format). Add no new WAV-specific coupling in the transport/proxy layers beyond what already exists.

Phase 4.2 — Server-side stream from disk (no buffer materialisation)

Status: Resolved as a consequence of Phase 4.1 landing on 2026-06-09. No separate implementation required.

  • What: The no-offset path already streams from disk — TrackController (~L390) takes mediaStream.Stream (a FileStream from LoadResourceStreamAsync), reads streamLength from .Length, and hands ownership to File(...); no LoadResourceAsync buffer materialisation on the default path. The remaining buffer materialisation is only the legacy ?offset= branch (~L414): GetAudioBinaryAsync loads the full AudioBinary into memory because WavOffsetService reslices over the in-memory buffer.
  • Why it matters: Scaling ceiling on the offset path specifically. Once 4.1 (A1) lands, the offset branch is off the player hot path, so its buffer cost stops mattering in practice.
  • Shape: Resolved for the default path. The only outstanding work is retiring the offset branch entirely — which is the 4.1 follow-up commit (remove the ?offset= server branch, WavOffsetService, and the now-unused ConcatStream). No separate work item beyond that cleanup.
  • Outcome: With Phase 4.1 landing and Range headers replacing the ?offset= query param as the transport mechanism, the offset branch is now definitively off the player's hot path. Buffer materialisation on that dormant code path is no longer a scaling concern. 4.2 is closed; the offset-branch cleanup is a follow-up housekeeping item, not a blocker.

Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls

Status: Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev).

Guard controls that are dead during the SSR→interactive handoff window (12s on fast loads, 5s+ on cold WASM cache) so they look inactive until the Blazor runtime attaches, then re-render into their live form. The listener reaches for play first — a play button that looks armed but eats the click reads as "the site is broken," not "the site is loading." This is a credibility/perceived-quality fix on the primary action.

Implementation approach: Extend the existing RendererInfo.IsInteractive pattern already established in PlayStateIcon.razor and DeepDrftHero.razor. Add Disabled="@(!RendererInfo.IsInteractive)" (or the HTML equivalent) to unguarded controls during the SSR phase. No global overlay/scrim (rejected — it fights the prerender's purpose and risks colliding with Blazor's #components-reconnect-modal); per-control guarding leaves the working parts (plain <a> links, idle UI) live. Each control carries its own inline gate — mild duplication over a shared <InteractivityGate> wrapper is deliberately accepted (over-engineering for ~4 call sites; would obscure the per-control rendering differences). Consistent with existing patterns.

Guarded controls (as implemented):

  • TrackCard.razor play MudFab (grid + list mode) — HIGHEST PRIORITY. Disabled during the gap (greyed, non-interactive via MudBlazor's built-in disabled state). Card looks composed but not-yet-armed, not alarmed. Re-enables once RendererInfo.IsInteractive flips. Note: /tracks bridges data across the seam via PersistentComponentState — but bridging data ≠ wiring handlers; the gap still exists on a cold WASM cache load.
  • TracksView.razor MudToggleGroup (grid/list switch) + MudPagination. Both gated to Disabled="true" during the gap. Lower priority than play, but cheap to include in the same pass and visually consistent.
  • SharePopover.razor (on TrackDetail). The Share MudIconButton trigger gated to Disabled="true" until interactive; the in-popover copy buttons are moot while the trigger is disabled, so the single guard on the trigger suffices.
  • DeepDrftMenu.razor "Stream Now" CTA. Folded !RendererInfo.IsInteractive into the existing disabled="@(...)" expression (e.g. disabled="@(_streamLoading || !RendererInfo.IsInteractive)") on both desktop and mobile buttons. The label-swap precedent here ("Finding a track…") is the house voice — disabling is the floor.

What was deliberately left untouched (mirrors WASM_SEAMS.md §2 discipline):

  • Minimized AudioPlayerBar dock — default state shows only LevelMeterFab, which is idle (untinted, no animation) until audio plays. Reads correctly during the gap; nothing to guard.
  • Expanded AudioPlayerBar transport zone — already routes its play/pause glyph through the guarded PlayStateIcon. Already covered by the existing pattern.
  • NowPlaying / NowPlayingCard — reflect live player state; show "Nothing playing" on both passes on a cold load. No dead control; the player is gesture-gated and intentionally non-persisted.
  • Plain <a href> links (track titles → /track/{key}, nav links, hero CTAs) — work in static SSR. Out of scope by construction.

Coexistence constraint: This guard targets the initial SSR→interactive handoff. It does not duplicate or interfere with Blazor's built-in #components-reconnect-modal (dropped-circuit recovery, a different lifecycle event). The two are orthogonal — RendererInfo.IsInteractive does not flip back to false on a reconnect, so the guards correctly stay inactive during a reconnect.

Prerequisite: None. Pure client-side rendering work in DeepDrftPublic.Client; no API or data-layer change.


LevelMeterFab — Continuous vertical fill animation

Status: Fully landed on 2026-06-08 (feature complete, component + CSS animation, merged to dev).

Replaced the discrete three-band tint model with a continuous vertical fill inside the music-note SVG silhouette. The fill height tracks live audio level bottom-up (0100%); a fixed three-zone gradient (linearGradient with gradientUnits="userSpaceOnUse") renders green (060% of note height), yellow (6085%), and orange (85100%) zones. The color at the fill line therefore changes naturally as the level rises. The note shape remains always visible as a dim silhouette at 25% opacity; idle (paused/stopped) shows the silhouette alone.

Implementation details:

  • C# side (LevelMeterFab.razor.cs): Removed discrete _bandClass field; replaced with continuous _fillPercent (0100). dB → fill % uses a linear map over a 30 to 0 dB window (30 dB = 0% fill, 0 dB = 100%, 12 dB = 60% / yellow boundary, 4.5 dB = 85% / orange boundary). Smoothing envelope operates on the continuous value (attack-fast / release-slow on dB, then map). Computed properties FillY and FillH expose the rect geometry to the SVG template.
  • SVG (LevelMeterFab.razor): Two layers — always-on dim silhouette (note path at 25% white) and a clipped fill group (rectangle revealed through the note via clipPath, painted with the zone gradient). No color cascade; explicit rgba on silhouette, explicit colors in gradient stops.
  • Gradient anchoring: linearGradient with gradientUnits="userSpaceOnUse" (not objectBoundingBox) — x1="0" y1="24" x2="0" y2="0" (bottom to top in viewBox coordinates). This pins the zones to fixed heights so the fill line always crosses the same colors at the same levels.
  • CSS (LevelMeterFab.razor.css): Removed band-tint color transition (no longer applicable). Geometry attributes y and height are not CSS-animatable in a reliable way; animation is purely the 30fps C# value updates driven by smoothing envelope. Silhouette remains always-on idle visual when _fillPercent = 0.
  • Re-render gate: 0.5% change threshold prevents churn on sub-pixel deltas; renders only on meaningful level swings.
  • Idle behavior: StopAnimation resets _fillPercent = 0 and _smoothedDb = SilenceFloorDb, dropping the column and leaving only the dim silhouette.

Supersedes the earlier discrete-tint LevelMeterFab entry from the same component. The new model is load-bearing for real-time level feedback on a commercial dance-music master (8 to 3 dBFS); the meter "breathes" through the green/yellow zones with peaks reaching orange, rather than holding in one band.


Status: Fully landed on 2026-06-08 (feature complete, component + layout + CSS, merged to dev).

Overview

Give the track gallery two switchable view modes behind a page-level toggle: Mode A — Album Art Grid (the current responsive 4-column MudGrid of 250×250 cards, augmented so that art-bearing cards hide their info overlay at rest and reveal it on hover) and Mode B — Track Detail List (a vertical stack of full-width horizontal rows, each a compact track line with play FAB, art thumbnail, artist/title text block, and right-aligned genre/year). The toggle is a two-option control at the top of TracksView, defaulting to Grid, with ephemeral page-level state (not persisted). Both modes consume the same ViewModel.Page.Items and the same per-card play-state inputs — the only divergence is in TrackCard's rendering, consistent with the "one source, multiple views" convention (CONTEXT.md §6).

Component changes

  • TracksView.razor / .razor.cs / .razor.css — Add an ephemeral ViewMode _viewMode = ViewMode.Grid field and a handler that flips it and calls StateHasChanged(). Render the toggle control above tracks-content (see Toggle spec). Pass ViewMode="@_viewMode" into <TracksGallery>. No change to data flow, persistence, or player-state subscription. CSS: a flex row for the toggle header (justify-content: flex-end).
  • TracksGallery.razor / .razor.cs / .razor.css — Add [Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;. Branch the template: for Grid, keep the existing MudGrid / MudItem breakpoint layout unchanged; for List, render a single flex-column container (deepdrft-track-list) that @foreach-es the same Tracks into <TrackCard> rows with no MudGrid wrapper. Pass ViewMode="@ViewMode" down to each TrackCard. The ActiveTrack / IsPlaying / IsPaused / OnPlay / OnPause wiring is identical in both branches.
  • TrackCard.razor / .razor.cs / .razor.css — Add [Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;. Branch the markup at the top: ViewMode.Grid renders the existing card body unchanged (plus the hover behaviour below); ViewMode.List renders the horizontal row layout (see Mode B spec). The hasLink / trackHref computation, PlayClick, and PlayPauseIcon are shared across both. The ViewMode enum lives in a small shared file (e.g. Controls/GalleryViewMode.cs or alongside TrackCard.razor.cs in the DeepDrftPublic.Client.Controls namespace) so both TracksView, TracksGallery, and TrackCard reference one definition.

Mode A — hover spec (pure CSS, no JS)

  • Applies only when the card has album art (deepdrft-track-card-bg present). The no-art fallback path (deepdrft-track-card-fallback) is untouched — its deepdrft-track-card-content stays visible at all times exactly as today.
  • For art-bearing cards: give deepdrft-track-card-content an opacity: 0 rest state and opacity: 1 on .deepdrft-track-card-container:hover .deepdrft-track-card-content. Add transition: opacity 180ms ease, background-color 180ms ease.
  • Swap the rest gradient for a solid navy panel on hover: at rest the content overlay is transparent/hidden; on hover its background becomes var(--deepdrft-navy-mid, #162437) (opaque, full-card) so the info reads cleanly over the art rather than through a gradient. Implement by toggling the background on the content layer between transparent (rest) and solid navy (hover), or by fading in a sibling navy panel beneath the content — implementer's call; the observable result is a solid navy reveal, not the current always-on gradient.
  • Distinguish art vs. no-art in CSS without new markup by scoping the hide/reveal rules to a container modifier. Add a class to the container when art is present (e.g. deepdrft-track-card-container--art) and gate the opacity: 0 rest rule on it, so fallback cards never pick up the hidden-at-rest behaviour.
  • Touch devices have no hover; on coarse pointers the overlay should default to visible. Guard the hidden-at-rest rule with @media (hover: hover) and (pointer: fine) so touch users always see the info.

Mode B — list row spec

  • Container: deepdrft-track-list is display: flex; flex-direction: column; gap: 8px; inside the existing MudContainer MaxWidth="Large". Rows are full-width.
  • Row (deepdrft-track-row): display: flex; flex-direction: row; align-items: center; gap: 16px; with height: ~7288px, padding: 8px 16px, and the same glass treatment as grid cards — background: var(--deepdrft-navy-mid, #162437), off-white text, border: 1px solid rgba(250,250,248,0.12). This reads on both light and dark themes (matches the fallback-panel rationale already documented in TrackCard.razor.css).
  • Columns, left to right:
    1. Play FAB — fixed-width column, vertically centered. Same <MudFab Color="Color.Tertiary" Size="Size.Medium" StartIcon="@PlayPauseIcon" OnClick="@PlayClick"/> as grid mode (reuse, do not duplicate logic).
    2. Art thumbnail — square ~64px (flex: 0 0 64px), vertically centered. Reuse the art background-image div for art-present; a deepdrft-track-card-fallback-style navy square for art-absent.
    3. Text blockflex: 1 1 auto; min-width: 0; two stacked rows: Artist (Typo.subtitle1, deepdrft-track-artist-weight) on top, Track Name (Typo.caption/body, deepdrft-track-title) below. Both text-truncate. Note the visual order here is Artist-over-Title, inverse of the grid card — intentional per the row sketch.
    4. Right metadata — fixed/flex: 0 0 auto column, text-align: right, two stacked rows: Genre chip (MudChip, same green-accent outline styling) top-right, Year caption bottom-right.
  • Linking: wrap the art + text columns in the same <a href="@trackHref" class="deepdrft-track-card-link"> pattern used by the grid card, so the row navigates to /track/{EntryKey} while the FAB (outside the anchor) remains the sole playback entry point. Preserve the display: contents approach so the flex row layout is unaffected by the anchor.
  • The active-state icon (PlayPauseIcon driven by IsPlaying/IsPaused) works identically — no list-specific play-state logic.

Toggle spec

  • Component: MudToggleGroup<ViewMode> with two MudToggleItems (icon-only), or a pair of MudToggleIconButtons — MudToggleGroup is the cleaner fit for a 2-value exclusive switch. Icons: Icons.Material.Filled.ViewModule (Grid) and Icons.Material.Filled.ViewList (List).
  • Placement: top of TracksView, above tracks-content, aligned right. Sits in its own header row; does not displace the existing centered gallery or the footer pagination.
  • Binding: @bind-Value="_viewMode" (or SelectedValue + SelectedValueChanged) on the toggle; the setter triggers re-render. State is a plain page field — not persisted to cookie or PersistentComponentState.
  • Default: ViewMode.Grid.
  • Skeleton/loading state (ViewModel.Page == null) is unaffected — keep the existing skeleton grid; the toggle may render disabled or hidden while loading (implementer's call).

Acceptance criteria

  • The TracksView page shows a two-option grid/list toggle, right-aligned at the top, defaulting to grid.
  • Grid mode, art card: at rest the card shows only album art (no title/artist/genre/year/FAB overlay); on hover a solid navy panel fades in over the art revealing all info and the play FAB; moving the pointer away hides it again. Transition is smooth (~180ms), no flicker.
  • Grid mode, no-art card: the navy fallback card shows title/artist/genre/year/FAB at all times, with no hover change — identical to current behaviour.
  • Touch / coarse-pointer devices: grid art cards show their info overlay by default (no permanently hidden info).
  • List mode: tracks render as a vertical stack of full-width rows, each ≤~88px tall, with play FAB at far left, ~64px art thumbnail (or navy placeholder), artist-over-title text block, and right-aligned genre chip over year.
  • Clicking a row (outside the FAB) navigates to that track's detail page; clicking the FAB plays/pauses without navigating, in both modes.
  • The play/pause icon and active state reflect the live player exactly as in grid mode, in both modes.
  • List rows are legible on both light and dark themes.
  • Toggling between modes is instant, preserves the current page and player state, and resets to grid on page reload (no persistence).

Out of scope

  • Persisting the selected view mode (cookie / PersistentComponentState / query string) — explicitly ephemeral this ticket.
  • Mobile-specific gestures (long-press, swipe) beyond the coarse-pointer hover fallback above.
  • Keyboard navigation beyond what the anchor + MudFab give by default; no roving-tabindex or arrow-key list traversal.
  • Any change to sorting, filtering, pagination, or the TracksViewModel data path.
  • Album/genre grouping views (covered separately under Phase 2.2).
  • Animation of mode transitions (cards/rows reflowing) — a plain re-render is acceptable.

Phase 2.5 — "Stream Now" — random-track instant play

Status: Fully landed on 2026-06-07 (feature complete, endpoints + service methods + menu wiring, merged to dev).

  • What: The nav-bar "Stream Now ▶" CTA (desktop and mobile, in DeepDrftMenu.razor) today just navigates to /tracks. Change it to pick a random track from the library and start playing it immediately, in place, without forcing the user onto the gallery page.
  • Why it matters: It is the single most prominent call-to-action on the site and currently does the least interesting thing — it dumps the listener on a grid and asks them to choose. "Stream Now" should mean now: one click, music plays. It is also the lowest-friction way for a first-time visitor to hear the collective's output, which is the whole point of the public site. Borrowed pattern: the "shuffle play" / "I'm feeling lucky" affordance (Spotify's shuffle, Bandcamp's "play random").

UX flow

  1. User clicks "Stream Now ▶" (desktop CTA or mobile menu item).
  2. Button enters a brief loading affordance (disabled + subtle pulse/spinner) while a track is selected — the selection requires at least one HTTP round-trip, so this is not instantaneous.
  3. A random track is chosen from the full library via GET api/track/random (server-side ORDER BY RANDOM() LIMIT 1).
  4. The player begins streaming that track via the existing AudioPlayerBar dock at the bottom of the layout. The dock is already cascaded into every page by AudioPlayerProvider in MainLayout, so it appears/animates in exactly as it does when a gallery card is clicked.
  5. The user does not navigate. They stay on whatever page they were on (most likely Home). Music plays; the dock is the player surface.
  6. On mobile, the menu closes (CloseMobileMenu) as part of the click, same as the existing nav links.

Edge cases

  • Empty library (TotalCount == 0): No track to play. The button surfaces a non-blocking, transient message ("No tracks yet") and does nothing else. Does not navigate, does not error-toast aggressively. This is a legitimate cold-start state, not a failure.
  • Metadata fetch fails (HTTP error): Surfaces a transient error on the button ("Couldn't reach the library — try again"), re-enables the button, does not navigate. Reuses the existing ApiResult failure check pattern (result is { Success: true, ... }).
  • Track fails to stream (selected track is valid metadata but the audio stream errors): Already handled downstream by StreamingAudioPlayerService / error handlers and surfaced through IPlayerService.ErrorMessage and the dock. Stream Now does not duplicate stream-error handling in the menu; it hands off to the same SelectTrackStreaming path every other play uses, and inherits that path's error behavior.
  • Player already playing something: Stream Now interrupts it and starts the random track. No confirmation prompt — "Stream Now" is an explicit user command to play something new.
  • Repeat clicks / same-track-twice: Acceptable for v1 to occasionally re-pick the currently-playing track. If it becomes annoying, a cheap "exclude PlayerService.CurrentTrack?.Id" filter on the candidate set is a one-line follow-up; noted for future.

Implementation

API endpoint (DeepDrftAPI):

  • New GET api/track/random (unauthenticated, mirroring GET api/track/page) returning a single TrackDto via ORDER BY RANDOM() LIMIT 1 (or the EF-Core equivalent) server-side.

Service methods:

  • New method on ITrackDataService / TrackClientDataService: Task<ApiResult<TrackDto?>> GetRandomTrack(), calling GET api/track/random via TrackClient.

Menu wiring (DeepDrftMenu.razor):

  • Injects ITrackDataService and cascaded IStreamingPlayerService. Click handler: calls GetRandomTrack(), on success calls PlayerService.SelectTrackStreaming(track), on empty/failure shows transient message.

AudioContext user-gesture constraint:

  • Browsers (Safari most strictly) only allow an AudioContext to start inside a user-gesture call stack. SelectTrackStreaming starts the context. Stream Now does an await GetRandomTrack() (network) before calling SelectTrackStreaming — an intervening await can lose gesture context on Safari. Mitigation: IStreamingPlayerService.WarmAudioContext() method added, called synchronous with the gesture at the start of the click handler, before the network await.

Acceptance criteria — as implemented

  • Clicking "Stream Now ▶" (desktop CTA) with a non-empty library selects a track uniformly at random (server-side) and begins streaming it via the existing dock, without navigating away.
  • Clicking "Stream Now ▶" in the mobile menu does the same and closes the mobile menu.
  • Selection issues exactly one HTTP request (GET api/track/random).
  • With an empty library, the button shows a transient "no tracks" message and does not navigate or throw.
  • With a failed metadata fetch, the button shows a transient error, re-enables, and does not navigate.
  • A track that streams-errors after selection surfaces through the existing player error path — no new error handling in the menu.
  • The menu component contains no track-fetch logic inline: selection goes through ITrackDataService.GetRandomTrack(); playback goes through PlayerService.SelectTrackStreaming. No duplication.
  • Audio plays on the first click after a cold load on Chrome and Safari — user-gesture/AudioContext constraint satisfied via WarmAudioContext() hook.
  • While selection is in flight, the button is disabled to prevent double-launch.

Phase 2.1 — Cover art / image vault wired through

Status: Fully landed on 2026-06-07 across three waves (Wave 1: API + vault; Wave 2-A: public proxy + TrackCard; Wave 2-B: CMS upload UI), merged to dev.

  • What: MediaVaultType.Image is implemented end-to-end and exercised by tests, but the production surface only registers a tracks vault of type Audio. ImagePath on TrackEntity is a free-form URL string today; it should resolve to an entry in an image vault served by DeepDrftContent.
  • Why it matters: Prerequisite for any album/release/genre view that wants to look like a music site rather than a list of rows. Also closes a free-form-string surface area that will otherwise calcify.
  • Shape:
    • Register a second vault (images or art, type Image) in Startup.ConfigureDomainServices and in the CLI.
    • Add GET api/image/{entryKey} (unauthenticated, mirrors track read) and PUT api/image/{entryKey} (ApiKey, mirrors track write) on DeepDrftContent.
    • Change TrackEntity.ImagePath semantics from "URL" to "image vault entry key" (column rename optional — could remain image_path with semantic shift, or could become image_entry_key for clarity).
    • Add an image processor sibling of AudioProcessor.
  • Prerequisite: None.
  • Constraint: This is a small schema-semantics migration. Existing rows have null ImagePath in production so there is no data to migrate, but commit before the field has real content to avoid a backfill.

Embeddable iframe player

Status: Feature complete on 2026-06-07 (commit c83b132 feature: Embed Frame Player, merged to dev).

A standalone, chrome-free player surface intended for embedding in an <iframe> on external pages (e.g. a Bandcamp-style "play this track here" widget on a third-party blog or the collective's socials). Distinct from the dock player, which lives inside the full site chrome.

Shape as implemented:

  • Layout/EmbedLayout.razor — a minimal layout: MudThemeProvider + AudioPlayerProvider wrapping @Body, with no nav, menu, or marketing chrome. Reuses the dark-mode PersistentComponentState round-trip (CONTEXT.md §3.6) so an embedded player still honours the theme.
  • Pages/FramePlayer.razor — routed at /FramePlayer, uses EmbedLayout, renders a single <AudioPlayerBar Fixed />. Reads a TrackEntryKey from the query string and auto-selects that track on load.
  • Services/ITrackDataService.cs + TrackClientDataService.cs — a new track-metadata fetch seam (GetPage + GetTrack(trackId)) so a component can resolve a single track by key without the gallery VM. Render-mode-agnostic (one seam, SSR and WASM both served by it).

Why it matters: An embeddable player turns every external mention of a DeepDrft track into a play surface. It is the lightest-weight distribution lever the product has — no app install, no account, just a link that plays. Fits the collective's "get the music in front of people" posture.

Deferred: CORS for arbitrary external embedders — handle when a concrete external host requires it.


Phase 1.1 — Backward seek

Status: Landed on 2026-06-07 (commits daa334a, 8581103 on seek-fix branch, merged to dev).

  • What: Seeking to a position below playbackOffset currently clamps silently to the start of the in-memory buffer segment instead of going to the user's chosen time. The forward "seek beyond buffer" path already exists in WavOffsetService + the client's offset-request path; backward seek is the missing mirror.
  • Why it matters: The single highest-impact missing feature in the player. Scrub-bar drags backward feel broken — they appear to seek but land in the wrong place.
  • Shape: Reuse the existing GET api/track/{id}?offset= pathway. The client decision becomes "is the target inside the decoded window?" — if yes, jump within the buffer (existing behaviour); if no (forward or backward), tear down the decoder and re-request from the byte-aligned offset.
  • Implementation: WaveformSeeker control supports both forward and backward seeking. The seek logic decides whether to jump within the decoded buffer or tear down and re-request from a byte-aligned offset regardless of direction. Backward seek observes the same blockAlign rounding-down as forward seek (enforced in WavOffsetService.alignedOffset and StreamDecoder.calculateByteOffset). Teardown/reinit respects the generation-counter pattern introduced by the concurrent-seek fix.

Phase 6 — Responsive home page (mobile layout)

Status: All six slices landed on 2026-06-07 (branches home-mobile-grid, home-mobile-hero, home-mobile-cta, merged to dev).

The home page (DeepDrftPublic.Client/Pages/Home.razor + Home.razor.css) is built entirely on hand-rolled CSS grids with no responsive breakpoints. Every horizontal split is a fixed column count that holds on desktop and collapses on mobile — six genre cards in one row, four feature cards in one row, two 50/50 splits, and a space-between CTA banner all overflow or squash below ~960px. This phase migrates the layout to be mobile-first while preserving the wireframe-faithful visual styling.

Guiding principle for the whole phase: separate layout from style. The scoped CSS in Home.razor.css does two jobs — it positions columns (the part that breaks on mobile) and it paints the design (colors, fonts, padding, hover states, pseudo-element flourishes). Only the column-positioning job migrates. Colors, typography, padding, ::before/::after decorations, and hover transitions stay in scoped CSS untouched.

Two tools, used deliberately:

  • MudGrid + MudItem (with xs/sm/md breakpoints) for splits where MudBlazor's margin-based gutters are acceptable: hero, section-header, section-split, CTA banner. This is the house pattern already used in DeepDrftShared.Client/Components/TracksGallery.razor (<MudItem xs="12" sm="6" md="4" lg="3">). Match it. Breakpoints: xs=0, sm=600, md=960, lg=1280, xl=1920. MudGrid breakpoint attributes are CSS-only at runtime — do not inject IBreakpointService or any breakpoint-observer service into the component.
  • CSS @media query on the existing scoped grid for the two card blocks (genre grid, features grid). These two are explicitly not MudGrid candidates — see 6.1 for why. Adding a media query that overrides grid-template-columns is the minimal, correct move there.

The one trap to avoid (read before touching the card grids): the genre grid and features grid use gap: 1px (genre) / shared border-right (features) to render the cards as a single block divided by hairline rules — the cards touch, and the 1px gap is the divider line. MudGrid's Spacing parameter produces margin-based gutters (multiples of 4px, with outer margin), which cannot reproduce a shared hairline edge. Porting these two grids to MudGrid would silently destroy the hairline-divider aesthetic. Keep them as CSS grid; only add breakpoints.

6.1 Genre grid + features grid — CSS media queries only

  • What: .genre-grid (repeat(6, 1fr)) and .features-grid (repeat(4, 1fr)) get responsive column counts via @media overrides in Home.razor.css. No markup change to the grid containers themselves.
  • Why MudGrid is wrong here: Both grids render cards as a contiguous block separated by 1px hairline rules (.genre-grid via gap: 1px over a border-colored background; .features-grid via per-card border-right). MudGrid's Spacing gutters are margins, not shared edges — switching would break the visual. Pure CSS keeps the hairline intact while still going responsive.
  • Stacking behavior:
    • Genre grid: md+ repeat(6, 1fr) (current); sm repeat(3, 1fr); xs repeat(2, 1fr). (Six genres divide cleanly into 3 and 2 — no orphan row.)
    • Features grid: md+ repeat(4, 1fr) (current); sm repeat(2, 1fr); xs 1fr (single column stack).
  • Scoped CSS that must change: Add two @media (max-width: 960px) and @media (max-width: 600px) blocks overriding grid-template-columns on .genre-grid and .features-grid. For .features-grid at the stacked/2-col breakpoints, the per-card border-right produces a dangling right border on the last card in each visual row — switch the hairline strategy at those breakpoints (e.g. apply border-bottom on cards and drop border-right, or move to gap: 1px like the genre grid). Specify the exact rule when implementing; the constraint is "no dangling/missing hairlines at any breakpoint."
  • Order of independence: Fully independent. Touches only Home.razor.css, no markup. Can be the first slice landed and verified in isolation.

6.2 Hero — MudGrid for content, CSS for the background color split

  • What: .hero is grid-template-columns: 1fr 1fr at min-height: 100vh, with .hero-left painted white and .hero-right painted navy — a full-viewport color split. Migrate the content columns to MudGrid; keep the background color split in CSS.
  • Why split the treatment: MudGrid rows/items do not carry per-column background colors that bleed to the full viewport height. The white/navy vertical split is a visual property of the section, not of the content columns. Wrap DeepDrftHero and NowPlaying in <MudItem xs="12" md="6"> inside a <MudGrid>, but keep the white/navy backgrounds on the section via CSS.
  • Stacking behavior:
    • md+: 50/50 split — hero copy left (white), NowPlaying right (navy). Current desktop look preserved.
    • xs/sm: stack to single column — DeepDrftHero on top, NowPlaying below. The 100vh constraint should relax to min-height: auto (or a smaller min) when stacked, so the two stacked panels don't each demand a full viewport.
  • Scoped CSS that must change:
    • .hero keeps min-height: 100vh at md+; add @media (max-width: 960px) relaxing it (e.g. min-height: auto) and switching the background from a left/right split to a top/bottom split (or letting each MudItem carry its own background at the stacked breakpoint).
    • The white/navy split: at md+ this can stay a CSS background on .hero (e.g. a linear-gradient(to right, white 50%, navy 50%) on the section, or backgrounds on the two MudItems via scoped classes). At xs/sm the split becomes top/bottom. Implementer picks gradient-on-section vs. background-per-item; the gradient-on-section approach survives the MudGrid gutter cleanly (gutters show the section background, not white margins).
    • Remove .hero's own display: grid; grid-template-columns: 1fr 1fr (MudGrid now owns column layout). Keep overflow: hidden.
  • Order of independence: Independent of all other sections. Has the most CSS nuance (the color split) — schedule it where there's time to verify the split holds at every breakpoint, including the MudGrid gutter not showing a white seam.
  • Constraint: DeepDrftHero and NowPlaying are child components with their own scoped CSS — do not refactor them in this pass. Layout is Home.razor's responsibility only.

6.3 Section header — MudGrid

  • What: .section-header is grid-template-columns: 1fr 2fr (label+title left, body paragraph right) with align-items: end. Migrate to MudGrid.
  • Stacking behavior: md+ keep the 1fr/2fr asymmetry via <MudItem md="4"> (title) + <MudItem md="8"> (body). xs/sm stack to xs="12" each — title block on top, body paragraph below.
  • Scoped CSS that must change: Remove display: grid; grid-template-columns: 1fr 2fr; gap: 4rem from .section-header. The align-items: end baseline-alignment is a desktop nicety that's meaningless when stacked — preserve it at md+ only (MudGrid Align.End on the row, or a scoped rule). .section-body's align-self: end similarly only applies in the side-by-side layout; harmless when stacked but can be dropped from the stacked breakpoint.
  • Order of independence: Independent. Small, low-risk — good warm-up slice.

6.4 Section split (origin + connect) — MudGrid

  • What: .section-split is grid-template-columns: 1fr 1fr at min-height: 60vh — green "Origin" panel left, white "Connect" panel right, each a full-bleed colored column. Same shape as the hero (colored columns) but lower stakes (60vh, not full-viewport, and the colors are per-panel not a single split).
  • Stacking behavior: md+ 50/50. xs/sm stack — Origin (green) on top, Connect (white) below.
  • Scoped CSS that must change: Replace the grid container with <MudGrid> + two <MudItem xs="12" md="6">. Here the per-panel backgrounds (.split-left green, .split-right white) live on the panels themselves, so — unlike the hero — the color survives a MudGrid gutter only if the gutter is removed or the panels fill their items edge-to-edge. Set MudGrid Spacing="0" so the green and white panels meet with no white seam between them, preserving the current flush-color-block look. The .split-left::before decorative circle stays untouched. Relax min-height: 60vh to auto at the stacked breakpoint so each panel sizes to its content.
  • Order of independence: Independent. The Spacing="0" decision here is the same family of problem as the hero seam — landing 6.2 first will surface the seam-handling approach to reuse here.

6.5 CTA banner — MudGrid or flex-wrap

  • What: .cta-banner is display: flex; justify-content: space-between — headline left, two action buttons right. .cta-actions is an inline flex row of two buttons.
  • Stacking behavior: md+ keep headline-left / actions-right. xs/sm stack — headline on top, actions below. At xs the two buttons should go full-width-stacked (or wrap) rather than sitting cramped side by side.
  • Approach — recommend the lighter touch: This one does not need MudGrid. The container is already flex; adding flex-wrap: wrap + a media query that flips flex-direction: column and align-items: stretch at max-width: 600px achieves the stack with the least churn. MudGrid is also fine (<MudItem xs="12" md="6"> × 2) if consistency with the other sections is preferred — but flex-column is fewer moving parts for a two-element banner. Pick flex unless the implementer wants every section uniformly on MudGrid.
  • Scoped CSS that must change:
    • .cta-banner: add @media (max-width: 600px)flex-direction: column; align-items: flex-start; gap: 2rem.
    • .cta-actions: add flex-wrap: wrap always; at xs, width: 100% with the two buttons (.btn-white, .btn-outline-white) going flex: 1 or full-width so they don't crowd.
    • The giant .cta-banner::before "DRFT" watermark (22rem) will overflow badly on mobile — add a media-query rule shrinking its font-size at xs (e.g. clamp or a fixed smaller size) or hiding it, so it doesn't force horizontal scroll. This is a hidden overflow source independent of the flex layout — do not skip it.
  • Order of independence: Independent. The watermark-overflow fix is the non-obvious part; the flex stack itself is trivial.

Phase 6 sequencing summary

All six slices are independent and touch only Home.razor + Home.razor.css (no child components, no shared CSS, no other pages). They can land in any order or in parallel. Recommended order by ascending risk: 6.3 (section header) → 6.1 (card grids) → 6.5 (CTA banner) → 6.4 (section split) → 6.2 (hero) — warm up on the trivial MudGrid swap, get the no-MudGrid card grids done, then tackle the two color-split sections (6.4, 6.2) last since they share the gutter-seam problem and the second reuses the first's solution.

  • Why it matters: The public site is the front door for a music collective whose listeners are disproportionately on phones (social-shared links, live-session discovery). A home page that overflows horizontally on mobile undercuts the entire "get the music in front of people" posture (PLAN.md in-flight iframe item makes the same bet). This is table-stakes polish, not a feature.
  • Prerequisite: None. Pure presentation work on one page.
  • Constraint: Do not refactor DeepDrftHero or NowPlaying (6.2 constraint). Do not touch DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (shared CSS) — all changes are scoped to Home.razor.css. Preserve every color/font/decoration; this phase changes where columns break, nothing about how the page looks at desktop width.

Play-State Icon Normalization

Status: Phases 14 landed on 2026-06-06 (branches track-card-play-state-wave1, track-card-play-state-wave2, merged to dev).

Landed 2026-06-06.

Bound TrackCard.IsPlaying to real playback state instead of selection identity. In TracksView/TracksGallery, active track is now computed as PlayerService.IsPlaying && CurrentTrack?.Id == track.Id. Switched the card glyph from MusicNote to the PlayArrow/Pause vocabulary via IsPaused and OnPause parameters. Expanded TracksView.OnPlayerStateChanged to re-render on any state change, not only on !IsLoaded — ensures the gallery correctly reflects pause, play, track-change, and end-of-playback transitions.

Component changes:

  • TrackCard.razor — added [Parameter] bool IsPaused, [Parameter] EventCallback OnPause parameters; removed MusicNote icon; now conditionally renders PlayArrow when not playing or Pause when playing.
  • TracksView.razor — removed _selectedTrack field (selection now fully derived from service); removed _clickCount, _lifecycleStatus, TestInteractivity dev scaffolding; OnPlayerStateChanged now calls StateHasChanged() unconditionally instead of only on !IsLoaded.
  • TracksGallery.razor — removed internal SelectedTrack mutation and StateHasChanged calls on play click; now fully controlled by parent; SelectedTrack parameter is read-only.

Architecture notes:

  • Resolves the reported bug: gallery card now shows correct play/pause icon reflecting actual playback state.
  • Enabling pause affordance on cards required extending TrackCard with IsPaused + OnPause, preserving the component's presentational contract (stays parameter-driven, lives in shared library).
  • TracksView.OnPlayerStateChanged subscription pattern unchanged; expansion from selective to unconditional re-render ensures high-frequency state changes (like spectrum animation or per-sample progress) do not cause visual lag in the gallery.

Phase 2 — Collapse dual selection state (SRP, prevents regression)

Landed 2026-06-06.

Eliminated divergence between TracksView._selectedTrack and PlayerService.CurrentTrack. TracksGallery is now fully controlled — the parent supplies and owns the active-track identity via parameter binding. Selection state is single-sourced from the player service.

Component changes:

  • TracksGallery.razor — removed parameter-field write in HandlePlayClick; no longer calls StateHasChanged() on click. Raises SelectedTrackChanged callback for the parent to route.
  • TracksView.razor — removed _selectedTrack backing field and its local mutation.

Architecture notes:

  • Resolves the secondary defect: gallery's notion of "active track" can no longer lag the player.
  • TracksGallery now a pure presentational component (reads SelectedTrack, raises SelectedTrackChanged, renders); all state derivation lives in the parent or the service.

Phase 3 — Introduce the single transport-state resolver (DRY)

Landed 2026-06-06.

Introduced a unified glyph-mapping source: PlaybackIcons.Resolve() static method in DeepDrftPublic.Client/Helpers/PlaybackIcons.cs. This is the sole function responsible for mapping (IsPlaying, IsPaused, trackId?, CurrentTrackId?) to the correct transport icon (PlayArrow, Pause, or null). Replaces all hand-rolled ternaries across TrackCard, PlayerControls, and other surfaces.

New code (DeepDrftPublic.Client/Helpers):

  • PlaybackIcons.cs — static Resolve(bool isPlaying, bool isPaused, long? trackId, long? currentTrackId) method returning (string? Icon, bool IsActive, bool IsPaused) tuple. Icon mapping is the single source of truth.

Component changes:

  • PlayerControls.razor(.cs)IsPlaying parameter removed from the AudioPlayerBar → PlayerTransportZone → PlayerControls chain. Instead, PlayerControls now subscribes to IPlayerService.StateChanged directly and calls PlaybackIcons.Resolve() to determine which icon to render and whether buttons are enabled/disabled.
  • TrackCard.razor — consumes the tuple returned by PlaybackIcons.Resolve() to set Icon, IsActive (CSS class for highlighting), and Disabled state on the FAB.

Architecture notes:

  • Eliminates the three-way duplication of "which icon for this state" logic.
  • Icon vocabulary is now standardized across all surfaces (PlayArrow/Pause pair, no MusicNote).
  • Future surfaces (queue list, now-playing chip, etc.) call the same Resolve() function instead of re-implementing the mapping.

Phase 4 (optional, deferred) — Promote to a PlayStateIcon component

Landed 2026-06-06.

Created a new PlayStateIcon.razor component in DeepDrftPublic.Client/Controls/ that encapsulates subscription + icon mapping + rendering. Rather than each surface calling PlaybackIcons.Resolve() and threading icons through parameters, surfaces now drop in <PlayStateIcon /> and the component handles cascading, state subscription, and icon selection in one place.

New component (DeepDrftPublic.Client/Controls/PlayStateIcon.razor):

  • Injects IPlayerService and subscribes to StateChanged on mount.
  • Cascades [CascadingParameter] DarkModeSettings DarkMode for theming.
  • Renders an icon button (or FAB) with the correct glyph via PlaybackIcons.Resolve().
  • Forwards Disabled parameter to the rendered MudIconButton/MudFab.
  • Raises OnClick callback when user clicks.

Component changes:

  • PlayerControls.razor — refactored to render its play/pause button via <PlayStateIcon /> instead of a parameter-driven button. IsPlaying parameter removed from the component signature.
  • The AudioPlayerBar → PlayerTransportZone → PlayerControls chain no longer threads IsPlaying/IsPaused down; subscription happens inside PlayStateIcon.

Architecture notes:

  • PlayStateIcon handles the seam between IPlayerService (source of truth) and transport-icon rendering (presentation). This was the third surface (after TrackCard and PlayerControls); Phase 4 was triggered by the appearance of the third call site.
  • Reduces parameter threading in the component tree (no more passing state flags through intermediate layers).
  • New surfaces that need play/pause icons (queue list, hover-row play button, etc.) now have a reusable, off-the-shelf component instead of re-implementing subscription and mapping.

WaveformSeeker Wave 3 — CMS PreProcessing panel

Status: W3 (CMS track-preprocessing panel) refactored on 2026-06-05 (branch waveform-w3-cms, merged to dev).

W3 — CMS PreProcessing panel

Landed 2026-06-05. Refactored 2026-06-05.

Implemented the CMS surface for on-demand waveform profile generation. Initial implementation created a new /tracks/preprocessing page; refactored to fold the preprocessing panel into TrackList.razor as a second MudTabPanel alongside the existing Tracks tab.

API endpoints (DeepDrftAPI):

  • GET api/track/waveform-status (ApiKey) — returns WaveformStatusDto[] with per-track profile existence (one entry per track in the database, indicating whether a profile sidecar exists in the vault).
  • POST api/track/{trackId}/waveform (ApiKey) — triggers on-demand profile compute and store for an existing track. Skips if profile already exists; errors surface gracefully (no profile → HTTP 404, track not found → HTTP 400).

Models (DeepDrftModels):

  • WaveformStatusDto — carries TrackId, EntryKey, TrackName, HasProfile boolean, and metadata for display/sorting.

CMS service (ICmsTrackService / CmsTrackService in DeepDrftManager):

  • GetWaveformStatusAsync() — service method wrapping the api/track/waveform-status call; returns Result<WaveformStatusDto[]> for error handling.
  • GenerateWaveformProfileAsync(entryKey) — service method wrapping the per-track generation endpoint; returns Result<bool> (success → true, profile already exists → true, error → false with result code).

CMS UI (DeepDrftManager/Components/Pages/Tracks/TrackList.razor):

  • Added "Preprocessing" MudTabPanel as the second tab in TrackList.razor, alongside the existing "Tracks" tab.
  • Table layout within the panel: track name, artist, "Profile Status" indicator (✓ or ○), with a per-row Generate button.
  • Sequential "Generate All Missing" bulk action button — iterates tracks with HasProfile == false, calls GenerateWaveformProfileAsync, shows progress. On completion, refreshes the table.
  • The standalone TrackPreProcessing.razor page at /tracks/preprocessing was eliminated; the page route is no longer exposed.
  • Nav link to preprocessing removed from Index.razor dashboard (consolidation makes a separate link unnecessary; the tab is discoverable from TrackList.razor).

Architecture notes:

  • Waveform generation on-demand (not automatic on upload like in W1) is intentional: Wave 1 profiles were computed for all future-uploaded tracks; Wave 3 adds a retroactive tool to populate profiles for existing tracks uploaded before Wave 1. The bulk action supports batching.
  • Service calls are fire-and-forget-result, not throw-on-error — GenerateWaveformProfileAsync returns a Result for the caller to inspect. This matches the FileDatabase philosophy (errors in compute/store are swallowed at the service boundary, callers check return values).
  • Profile endpoint uses the same WaveformProfileService that computes profiles during upload — no new algorithm or storage path introduced. CMS can only trigger on-demand what the upload path does automatically.
  • HTTP cache headers are deferred (same as W1-T2). Each api/track/waveform-status call lists all tracks and their current state; this is acceptable for the admin surface where refreshes are infrequent.
  • Consolidation rationale: Folding the preprocessing panel into TrackList reduces UI fragmentation — track management (list, add, edit, delete, preprocess) lives in one cohesive view rather than split across separate pages. The tab structure keeps preprocessing distinct from the main track listing without requiring a dedicated route.

WaveformSeeker Wave 2 — DOM seekbar + Interop module

Status: W2 (WaveformSeeker component) landed on 2026-06-05 (branch waveform-w2-seeker, pending merge to dev).

W2 — WaveformSeeker component (seekbar replacement)

Landed 2026-06-05.

Implemented the interactive WaveformSeeker component: a bar-chart-styled seekbar replacing MudSlider in PlayerSeekZone, with DOM-rendered progress split via CSS and lazy-loaded pointer-capture drag interop.

Component changes (DeepDrftPublic.Client/Controls/AudioPlayerBar):

  • WaveformSeeker.razor (+ .cs, .css) — new component consuming WaveformProfile double[]? and Duration, rendering bars as DOM elements with clip-overlay progress. Single CSS variable (--seek-position) changes per seek gesture; no per-bar re-render.
  • Pointer-capture drag wired via waveformSeeker.js (ES module, lazy-loaded). Calculates seek target from click/drag position and invokes OnSeekRequested callback (delegates to IPlayerService.SeekAsync).
  • Flat floor-height fallback when profile is unavailable — seek gesture always works, with or without loudness data.
  • PlayerSeekZone.razor — now hosts WaveformSeeker in place of the removed MudSlider placeholder.

Interop changes (DeepDrftPublic/Interop/audio/):

  • New waveformSeeker.ts module (separate from the TS audio bundle) — PointerCaptureHandler class managing pointerdown / pointermove / pointerup lifecycle. Compiled to waveformSeeker.js in wwwroot/js/audio/.
  • Module loaded on first use (not bundled with audio stack) to defer its parse cost until the player is expanded and the seekbar is visible.

.gitignore scoping:

  • Added scoped negation to track hand-authored waveformSeeker.js alongside existing TS-output ignore rule — allows the compiled JS to be committed for fast startup without committing intermediate TS compiler outputs.

Service changes (IPlayerService / AudioPlayerService / StreamingAudioPlayerService):

  • New WaveformProfile double[]? property added to service interface and implementations.
  • Fetched fire-and-forget on track load via GetWaveformProfileAsync(trackId, cancellationToken) — existing HTTP call from W1-T2.
  • Cancellable via the track-reset flow (same cancellation token that stops spectrum animation).
  • Cleared on reset with all other track state.

Testing:

  • Manual verification: seekbar renders flat when profile unavailable; dragable when profile present; CSS clip-overlay tracks seek position correctly.

Architecture notes:

  • WaveformSeeker does not re-fetch the profile — it consumes the same IPlayerService.WaveformProfile fetched during track load. No additional HTTP round-trip per seek gesture.
  • Interop module (waveformSeeker.js) is independent of the audio playback stack — can be updated or replaced without touching audio scheduling logic.
  • Pointer-capture semantics ensure seek is responsive even when the browser's event queue is saturated by animation frames.
  • Flat fallback ensures seek gestures always work, even on tracks with no profile data (uploaded before W1, or on profile-generation failure).

WaveformSeeker Wave 1 — Loudness profile + layout refactor

Status: W1-T1 (backend loudness computation), W1-T2 (HTTP transport), and W1-T3 (player layout refactor) landed on 2026-06-05.

W1-T1 — Backend waveform loudness profiling

Landed 2026-06-05.

Implemented Phase 1 of the WaveformSeeker feature (product-notes/spectrum-seeker.md): loudness-profile computation and storage for preprocessed waveform data.

Backend changes (DeepDrftContent):

  • Added ILoudnessAlgorithm strategy interface for swappable loudness computation.
  • Implemented RmsLoudnessAlgorithm — first loudness algorithm using root-mean-square; future LUFS implementation swaps in via the same interface without touching service, wire format, or storage.
  • WaveformProfileService — computes peak-normalized loudness profile from PCM WAV (one linear buffer pass), buckets by time slice, normalizes to [0,1], stores as byte-quantized sidecar in new profiles vault (FileDatabase MediaFileVault).
  • WaveformProfileOptions — config-bound options object carrying BucketCount (default 512) and future algorithm-selection knobs.

Integration changes (DeepDrftAPI):

  • Wired WaveformProfileService into UnifiedTrackService.UploadAsync — profile computed on upload, stored immediately, failure silently swallowed (consistent with FileDatabase philosophy in CLAUDE.md).

Models (DeepDrftModels):

  • WaveformProfileDto — carries quantized profile data; format independent of algorithm or bucket count.

Testing (DeepDrftTests):

  • 4 new unit tests: RMS algorithm correctness against known-good PCM samples, swappable-algorithm contract (two strategies swap cleanly), and integration with WaveformProfileService.

Architecture notes:

  • Profile is derived binary content; stored in FileDatabase vault sidecar per CLAUDE.md principle ("binary content lives in the vault").
  • Loudness measure is an abstraction (not hardwired RMS) — RMS→LUFS future change requires only a new ILoudnessAlgorithm implementation, no refactoring of service, component, or wire format.
  • No external audio-processing dependency pulled in for RMS — reuses existing PCM parser from AudioProcessor.
  • Cost: one linear pass over PCM buffer at upload (few hundred ms for typical WAV); never on playback path.

W1-T2 — Waveform profile HTTP transport

Landed 2026-06-05.

Implemented Phase 2 of the WaveformSeeker feature: HTTP transport layer for waveform profile data from backend to client, enabling client-side display of loudness profiles in future seeking UI.

API endpoint (DeepDrftAPI):

  • New GET api/track/{trackId}/waveform endpoint — unauthenticated, returns WaveformProfileDto (base64-encoded quantized bytes + BucketCount) on success, 404 if track or profile not found.
  • Leverages existing WaveformProfileService to load profile from vault on demand.
  • No authentication required — mirrors GET api/track/{id} streaming policy (public audio access).

Proxy forward (DeepDrftPublic):

  • Thin buffered forward in TrackProxyController — proxies request from client to DeepDrftAPI waveform endpoint with same path parameters.
  • Preserves error semantics: 404 from API passes through to client; network errors surface as HTTP errors.

HTTP client (DeepDrftPublic.Client):

  • New TrackMediaClient.GetWaveformProfileAsync(trackId, cancellationToken) method on the content HTTP client.
  • 404 response maps to Result.Failure (fail-result signal for WaveformSeeker to render flat fallback).
  • Network/timeout errors map to separate Result.Failure with distinct code.
  • Callsite can discriminate via result error code whether to retry (transient) or render fallback (not found).

Architecture notes:

  • Transport layer is independent of loudness algorithm (W1-T1) — client receives opaque quantized bytes; future algorithm changes on backend do not affect wire format, as long as BucketCount is included.
  • HTTP caching via ETag/Last-Modified is deferred to Phase 2 optimization work.
  • Profile loading from vault is on-demand (not pre-cached in memory) — load cost amortizes across all requests to the same track.
  • 404 handling unambiguous: client renders flat fallback, distinguishing "track has no profile" from "track not found" via error code.

W1-T3 — Player layout refactor (SpectrumVisualizer relocation + VolumeZone rename)

Landed 2026-06-05.

Implemented Phase 3 of the WaveformSeeker feature: architectural layout move separating live-spectrum visualization from loudness-over-time seeking.

Conceptual split:

  • Live-spectrum (FFT frequency bars, SpectrumVisualizer) moved from PlayerSeekZone → stacked above the volume slider in new VolumeZone. Conceptually with the output level.
  • Static loudness-over-time (future WaveformSeeker) takes over the seek zone. Conceptually with transport position.

Component changes (DeepDrftPublic.Client/Controls/AudioPlayerBar):

  • VolumeControls.razor → renamed VolumeZone.razor for symmetry with transport and seek zones; now a vertical stack hosting SpectrumVisualizer above the volume slider.
  • SpectrumVisualizerBucketCount parameter defaulted to 24 buckets (down from 32) to fit the narrow volume cluster; set flex-shrink: 0 to pin the spectrum to a fixed footprint above the volume control.
  • PlayerSeekZone.razorSpectrumVisualizer block removed; placeholder for future WaveformSeeker component.

CSS changes (AudioPlayerBar.razor.css):

  • Adjusted volume cluster width constraints to accommodate the 24-bucket spectrum stacked above.
  • Responsive layout unchanged at 600px breakpoint (single-row transport/volume with full-width seek below on narrow; same 3-zone layout on wide).

Scope:

  • Pure layout move; zero change to spectrum animation lifecycle, player logic, or seek gesture handling.
  • Both AudioPlayerBar and SpectrumVisualizer components affected.
  • Build clean: 0 errors, 0 new warnings.

Notes for future work:

  • PlayerSeekZone is now ready for the WaveformSeeker component (W1-T4/Phase 4 onwards).
  • Volume cluster can comfortably accommodate 24 FFT bars; 32 would cause visual cramping (why the override exists).
  • Spectrum visualization lifecycle (subscription to StateChanged, animation via AudioInteropService.StartSpectrumAnimationAsync) unchanged — only position in the DOM tree changed.

Phase 2 — Product surface: player and theming

Status: Track card CSS scoping landed on 2026-06-05. Track card glass theming landed on 2026-06-05. AudioPlayerBar responsive unification and SpectrumVisualizer fix landed on 2026-06-05. Track view CSS consolidation landed on 2026-06-05.

Track Card CSS Scoping

Landed 2026-06-05.

Moved track card rules from the global stylesheet into an isolated scoped stylesheet, eliminating style leakage and enabling independent maintenance of the component's appearance.

CSS changes:

  • DeepDrftPublic/wwwroot/styles/deepdrft-styles.css §8 — removed all track card rules (.deepdrft-track-card-*, .deepdrft-track-title, .deepdrft-track-artist, .deepdrft-track-meta); replaced with a pointer comment directing readers to TrackCard.razor.css.
  • DeepDrftShared.Client/Components/TrackCard.razor.css — created new scoped stylesheet with all card rules: container styling, text-colour hierarchy (title, artist, meta), theme-variant selectors (.deepdrft-theme-dark / .deepdrft-theme-light), and glass background + border styling.
  • Applied ::deep pseudo-selector to the three MudText text-color rules (deepdrft-track-title, deepdrft-track-artist, deepdrft-track-meta) so CSS isolation doesn't suppress colour overrides on MudBlazor elements.
  • Eliminated all theme-variant selectors in favour of a single-vocabulary colour scheme: navy-glass fallback, --deepdrft-white title, --deepdrft-green-accent artist, rgba(250,250,248,0.45) meta. Matches the NowPlayingCard aesthetic.
  • DeepDrftShared.Client/Components/TracksGallery.razor.css — moved .deepdrft-track-gallery-item-center layout rule from global stylesheet into scoped CSS alongside the existing gallery container rules.

Scope:

  • Affected components: TrackCard.razor (shared, consumed by public site and CMS) and TracksGallery.razor (shared).
  • CSS in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (global) and two scoped stylesheets.
  • Build clean: 0 errors, 0 new warnings.

Architecture notes:

  • CSS isolation now protects track card rules from accidental mutation by unrelated global changes.
  • Light-mode visual is now consistent: single vocabulary eliminates the three-green collision and establishes a stable text hierarchy (off-white title → muted artist → fainter meta).
  • Scoped stylesheet pattern mirrors existing usage in other components (AudioPlayerBar.razor.css, NowPlayingCard.razor.css), establishing a consistent maintenance model.

Track View CSS Consolidation

Landed 2026-06-05.

Implemented CSS consolidation and hierarchy fixes across three components: removed dead layout rules, unified horizontal inset ownership, and resolved the three-green collision in dark mode by demoting artist text and changing the genre chip variant.

Component changes:

  • DeepDrftPublic.Client/Pages/TracksView.razor — removed dead tracks-page-wrapper class and associated inert flex/height/padding rules; MudContainer now owns horizontal inset via MaxWidth.Large.
  • DeepDrftShared.Client/Components/TracksGallery.razor.css — reduced to box-sizing: border-box; removed redundant padding and inert height constraint.
  • DeepDrftShared.Client/Components/TrackCard.razor — changed genre chip from Variant.Filled to Variant.Outlined to distinguish it from the play FAB.

CSS changes (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css §8):

  • Text color rules restructured: base color: inherit, both dark and light treatments guarded under .deepdrft-theme-dark / .deepdrft-theme-light ancestors at 0,2,0 specificity.
  • Artist text demoted from green-accent to rgba(250,250,248,0.65) in dark mode (leaving green as a purely accent/interactive signal — FAB and chip border).
  • Meta text (album/year) at rgba(250,250,248,0.45) in dark mode.
  • Genre chip treatment now supports outlined styling (borders + text only, no filled ground).

Scope:

  • CSS in deepdrft-styles.css and scoped stylesheets for TracksView.razor and TracksGallery.razor.
  • Both DeepDrftPublic.Client and DeepDrftShared.Client components affected.
  • Build clean: 0 errors, 0 new warnings.

Architecture notes:

  • Resolved the three-green visual hierarchy collapse (artist + genre chip + play FAB all rendered the same saturated green). Now: title off-white, artist muted, genre = outlined green tag, FAB = solid green action — a clear three-tier hierarchy matching NowPlayingCard vocabulary.
  • Consolidated horizontal inset ownership to MudContainer (removes duplicate paddings that stacked across three layers).
  • Removed inert flex-grow and height rules that encoded a sticky-footer intent that was not actually achieved; page layout via normal block flow is cleaner.

Status:

Track Card Glass Theming

Landed 2026-06-05.

Aligned TrackCard component visual language with the NowPlayingCard aesthetic via glass background + text hierarchy. Two coordinated changes:

Razor changes (DeepDrftShared.Client/Components/TrackCard.razor):

  • Removed mud-theme-secondary class and Color="Color.Surface" attributes from all four MudText elements, handing color control to CSS.
  • Added semantic class hooks: deepdrft-track-title (track name), deepdrft-track-artist (artist), deepdrft-track-meta (album and release year).
  • Changed MudCard Elevation="4"Elevation="0" to align with glass-panel vocabulary (no drop shadow).

CSS changes (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css §8):

  • Dark theme: navy-glass fallback panel (color-mix(in srgb, var(--deepdrft-navy) 55%, transparent) + backdrop-filter: blur(8px) + translucent border), matching NowPlayingCard glass vocabulary.
  • Text hierarchy (dark): title in off-white, artist in moss-green accent, meta in muted off-white — mirrors the NowPlayingCard hierarchy.
  • Content scrim behind text (dark): dark navy gradient to guarantee legibility over both glass fallback and album art.
  • Light theme: subtle navy-tint fallback on off-white, light text inherits body colour for legibility.
  • Glass border on card container (dark): 1px solid rgba(250, 250, 248, 0.12) for aesthetic consistency.

Scope:

  • TrackCard component in shared DeepDrftShared.Client consumed by both public site and CMS.
  • CSS in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (public site only, not loaded by CMS).
  • Build clean: 0 errors, 0 new warnings.

Notes for future work:

  • Genre chip text still uses Color.Primary (moss-green); it now sits alongside moss-green artist text. Consider a distinct genre-chip treatment (3a) in future polish work.

Status: AudioPlayerBar responsive unification and SpectrumVisualizer fix landed on 2026-06-05.

AudioPlayerBar Responsive Unification

Landed 2026-06-05.

Collapsed the two divergent Razor trees in AudioPlayerBar.razor (@if (_isDesktop) / @else) into a single markup tree where CSS — not a runtime breakpoint flag — drives the responsive layout. Removed IBrowserViewportService, the _isDesktop field, OnAfterRenderAsync, and the viewport subscription/unsubscription from the code-behind.

Structural changes:

  • Single .player-layout flex container (in AudioPlayerBar.razor.css) replaces the dual-branch conditional. Three children (PlayerTransportZone, VolumeControls, PlayerSeekZone) in source order; media query at 600px (Sm breakpoint) reorders via CSS order property and forces SeekZone to full-width below the transport/volume row on narrow viewports.
  • PlayerTransportZone flips its internal axis (vertical ↔ horizontal) via scoped CSS override of MudStack flex-direction at the 600px boundary — no parameter added to the component.
  • ::deep prefix removed from MudBlazor component-class selectors in PlayerTransportZone.razor.css now that axis is purely CSS-driven and no runtime flag determines structure.
  • SpectrumVisualizer bars now appear on first expand — fixed by subscribing to the multicast StateChanged event (same pattern used by AudioPlayerBar), ensuring animation is initialized after mount.

Scope:

  • Unified responsive layout (desktop/mobile branches merged into single tree).
  • Both AudioPlayerBar and SpectrumVisualizer components affected.
  • Build clean: 0 errors, 0 new warnings.

Notes for future work:

  • First-render layout flash eliminated by construction (CSS media query evaluates at paint, not async subscription).

Track Card Plain-Shell Refactor

Landed 2026-06-05.

Eliminated !important declarations from track card CSS by replacing MudBlazor surface components with plain HTML. Implemented per product-notes/track-card-css-architecture.md Option A.

Razor changes (DeepDrftShared.Client/Components/TrackCard.razor):

  • MudCard<div class="deepdrft-track-card-container">
  • Fallback MudPaper<div class="deepdrft-track-card-fallback">
  • MudCardContent<div class="deepdrft-track-card-content">
  • MudText, MudChip, MudFab unchanged.

CSS changes (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css §8):

  • Removed four !important declarations from .deepdrft-track-card-container, .deepdrft-track-card-fallback base, and the dark/light theme-scoped variants.
  • Plain single-class selectors now win by cascade without !important; theme-scoped rules use normal specificity hierarchy.

Scope:

  • TrackCard component in shared DeepDrftShared.Client consumed by both public site and CMS.
  • CSS in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (public site only).
  • Build clean: 0 errors, 0 new warnings.

Notes for future work:

  • Plain-div shell re-enables CSS isolation as an option (a TrackCard.razor.css would now work against the shell divs). Section 8's public-only scoping remains convenient; isolation is optional for future polish.
  • Removes the structural mismatch of using a Material surface component (MudCard/MudPaper) solely as a layout shell. TrackCard now mirrors the construction of NowPlayingCard (plain divs + themed CSS).

Track Detail Page (/track/{entryKey})

Status: Landed on 2026-06-06 (branch track-detail-page, merged to dev). Cover art integration completed on 2026-06-08.

A focused, editorial single-track view in DeepDrftPublic.Client. The track gallery answers "what is in the library"; this page answers "tell me about this track" — full metadata, cover art, and a single prominent play affordance, styled to feel like a record-sleeve back-cover rather than a form. Link-only for now (reached from a gallery card / Now Playing), not a top-level nav entry.

Implemented solution

Components (DeepDrftPublic.Client/Pages/):

  • TrackDetail.razor + TrackDetail.razor.cs — routed at @page "/track/{EntryKey}" with @rendermode InteractiveWebAssembly. Three render states (loading skeleton, loaded layout, 404 not-found) driven by TrackDetailViewModel flags. Cascades IStreamingPlayerService for play-affordance wiring. Subscribes to PlayerService.StateChanged to keep the play button label in sync with live transport state.

ViewModel (DeepDrftPublic.Client/ViewModels/):

  • TrackDetailViewModel — scoped, registered in Startup.ConfigureDomainServices. Depends on ITrackDataService (render-mode-agnostic seam, existing). Properties: Track (loaded DTO), IsLoading, NotFound. Single Load(entryKey) command idempotent per route, fully resetting all three flags on each call to prevent stale track bleed on navigation.

DI registration (DeepDrftPublic.Client/Startup.cs):

  • TrackDetailViewModel registered scoped.

UI layout:

  1. Subtle back-link ← All tracks to /tracks, muted low-emphasis text affordance.
  2. Large square cover art block — displays album art via a MudPaper div with background-image: url('api/image/{entryKey}') when ImagePath is present; falls back to placeholder themed MudPaper with Album glyph when cover unavailable.
  3. Title (TrackName, display-serif h3) / artist (h6, primary accent) masthead.
  4. Prominent Play button under masthead with state-reactive label ("Play" / "Pause" / "Resume" keyed to current track and playback state via PlayerService subscription).
  5. MudDivider separator.
  6. Optional-field metadata block (Album, Genre, ReleaseDate) — definition-row layout, rendered only if non-null; all three omit silently if unavailable.
  7. Skeleton loading state matching the loaded layout silhouette.
  8. 404 messaging on not-found.

CSS classes (DeepDrftPublic/wwwroot/styles/deepdrft-styles.css §14):

  • deepdrft-track-detail-container — centered single column, max-width, auto-margins, vertical padding.
  • deepdrft-track-detail-cover — square aspect-ratio frame, rounded, subtle shadow/border (light/dark theme-aware), overflow: hidden for clean image crop.
  • deepdrft-track-detail-cover-art — applied to MudPaper div; sets background-size: cover, background-position: center for responsive fill within the cover frame.
  • deepdrft-track-detail-masthead — title/artist spacing, display-serif via existing deepdrft- font classes.
  • deepdrft-track-detail-meta — metadata block rhythm, small-caps muted labels.
  • deepdrft-track-detail-back — back-link affordance, muted color, hover treatment.

Inbound links wired (DeepDrftShared.Client/Components/TrackCard.razor):

  • Cover block and title/artist are now display:contents anchors to href="/track/{track.EntryKey}", making the entire card clickable to the detail page.
  • Play button on the card untouched (still functions independently for gallery playback).

Architecture notes:

  • Render mode InteractiveWebAssembly (server prerender → WASM hydrate) mirrors TracksView consistency.
  • TrackDetailViewModel is scoped (per-instance), not singleton — navigating between /track/A and /track/B reuses the same scoped instance, so Load must fully reset state to prevent cross-navigation bleed.
  • Play button implements the same PlayerService.StateChanged subscription pattern as TracksView — mandatory for label coherence when the dock bar drives state.
  • Cover-art integration (2026-06-08): the page now displays album art via a MudPaper div with background-image: url('api/image/{entryKey}') when ImagePath is present; a placeholder with the Album glyph renders when unavailable. CSS background rendering degrades gracefully (blank surface) if a vault entry is missing.
  • Page is link-only navigation (not in the header MenuPages); reachability depends on inbound links from TrackCard and Now Playing surfaces, which were wired simultaneously.

Status: Desktop AudioPlayerBar redesign landed on 2026-06-04.

Desktop AudioPlayerBar — migrate to MudBlazor theme system

Landed 2026-06-04.

Desktop branch of AudioPlayerBar.razor migrated off dead CSS palette tokens (--charleston-*, --lowcountry-*, --deepdrft-theme-* — none of which are defined in the live stylesheet) onto the active MudBlazor theme system. This was simultaneously a bug fix (player styling broken against the current palette) and a structural redesign.

Structural changes:

  • .player-backdrop div replaced with MudPaper Elevation="8" — surface colour now derives from --mud-palette-surface via the live theme, and flips automatically with dark mode (off-white in light, navy in dark).
  • Three new zone sub-components extracted: PlayerTransportZone (left transport cluster), PlayerSeekZone (centre seek+spectrum, owns the seek pointer-handler logic), PlayerWindowControls (minimize/close buttons). These remove duplication (seek handlers no longer inline-copied) and name the layout zones explicitly.
  • MudStack replaces all raw <div class="d-flex gap-*"> throughout the desktop branch and sub-components (PlayerControls, VolumeControls, TimestampLabel).
  • SpectrumVisualizer bar colour fixed: var(--mud-palette-primary) replaces the undefined --deepdrft-theme-secondary token.
  • Minimized dock replaced with MudFab Color="Color.Primary" — rounded button picking up themed primary colour with no hand-rolled gradient.
  • AudioPlayerBar.razor.css shrunk from ~176 lines (mostly dead-token theming) to ~74 lines (geometry and positioning only).

Scope:

  • Desktop branch only (@if (_isDesktop)). Mobile branch unchanged by design.
  • Build clean: 0 errors, 0 new warnings.

Notes for future work:

  • Mobile branch is also currently broken against the live palette for the same reason (spectrum bars + shared dead-token rules have no colour). A companion migration for mobile is implied but out of scope for this task — marked for future Phase 2 work.

Deployment Infrastructure

Status: CD pipeline infrastructure landed on 2026-06-04.

CD pipeline infrastructure (Gitea workflows + remote host installer)

Landed 2026-06-04.

Continuous deployment infrastructure for DeepDrftHome dual-app deployment. Consists of four Gitea workflows (.gitea/workflows/) — deploy-public.yml, deploy-manager.yml, deploy-api.yml, package-install.yml — all triggered by dev branch (beta) and master branch (prod) pushes, path-filtered to deploy only on changes to the affected service and its dependencies. Five installer scripts (deploy/) — install.sh (one-shot host provisioner), bootstrap.sh (curl-and-run entry point), ssh-wrapper.sh (forced-command dispatcher), three deploy-*.sh per-service deployment scripts — plus systemd service templates (deploy/systemd/) and nginx vhost templates (deploy/nginx/), and credential template files (deploy/credentials/). One auxiliary setup script setup-step10-creds.sh for interactive credential entry on the host. The installer creates users, directories, systemd services, PostgreSQL databases, nginx vhosts, and loads credential files via systemd LoadCredential= into the credential sandbox. The deploy scripts swap binaries in-place, run the EF migrations bundle for the API metadata database, and restart services without touching persistent vault data. Enables hands-off pushes to beta and prod with full CI/CD orchestration.


Two-app split Wave 2 — Phase 4

Status: Phase 4 (project rename) landed on 2026-05-19.

Phase 4 — Two-app split: rename DeepDrftWebDeepDrftPublic

Landed 2026-05-19.

Renamed DeepDrftWeb to DeepDrftPublic and DeepDrftWeb.Client to DeepDrftPublic.Client across all project files, .csproj files, namespace declarations, using directives, solution file, and deploy scripts. Updated all references in CLAUDE.md agent guidance to reflect the new names. Also updated prior references to DeepDrftWeb.Services to DeepDrftData to align with the Phase 2 library rename. The solution builds cleanly with all endpoints functional.


CMS Wave 1 — Auth + scaffolding + parity

Status: All sub-items landed on 2026-05-18.

W1.0 DeepDrftContext Postgres migration

Landed 2026-05-18.

Rewrite all existing EF Core migrations from SQLite to PostgreSQL. Update the DeepDrftWeb and DeepDrftCli connection strings in config. Migrate any existing data from ../Database/deepdrft.db to Postgres. Verify the existing api/track/page and api/track/{id} endpoints function against the new backend. This is a prerequisite for W1.2 (which also runs migrations for AuthDbContext against the same Postgres instance).

W1.1 DeepDrftCms RCL skeleton

Landed 2026-05-18.

Project created, added to solution, referenced from DeepDrftWeb. Empty Pages/Cms/Index.razor mounted at /cms returning a "CMS — under construction" placeholder, proving the mount works.

CMS RCL inlined into DeepDrftManager

Landed 2026-05-21.

The DeepDrftCms Razor Class Library has been inlined into DeepDrftManager and the standalone project deleted from the solution. All Razor pages, components, and layouts (CmsLayout, DeleteTrackDialog, TrackList, TrackNew, TrackEdit, and the CMS index page) now live directly in DeepDrftManager/Components/Pages/Cms/, DeepDrftManager/Components/Pages/Tracks/, DeepDrftManager/Components/Layout/, and DeepDrftManager/Components/Shared/. The DeepDrftManager.csproj no longer references the now-deleted DeepDrftCms project. DeepDrftManager/Program.cs no longer calls AddCmsServices() or references the CMS assembly. Solution builds cleanly with all CMS endpoints and pages functional.

W1.2 AuthBlocks integration + login

Landed 2026-05-18.

Reference Cerebellum.AuthBlocks, Cerebellum.AuthBlocks.Web, Cerebellum.AuthBlocks.Models from DeepDrftWeb; reference Cerebellum.AuthBlocks.Web from DeepDrftWeb.Client. Call AddAuthBlocks(...) in Program.cs with JWT secret/issuer/audience, Mailtrap email connection, Postgres connection string, and AdminUserSettings from environment/authblocks.json. Call await app.Services.UseAuthBlocksStartupAsync() post-build. Call app.MapAuthBlocks() to mount /api/auth/* routes. Add the AuthBlocksWeb assembly to AddAdditionalAssemblies so the bundled /account/login and /account/logout pages resolve. In DeepDrftWeb.Client.Startup, call AuthBlocksWeb.Client.Startup.ConfigureServices(builder.Services) for the prerender→WASM auth-state bridge. Add CreatedByUserId : long? column to TrackEntity via a nullable migration. Provision local Postgres (docker-compose) and document the dev setup. Includes CmsStealthRoutingHandler — a custom IAuthorizationMiddlewareResultHandler that returns 404 for any /cms/* hit that fails authorization, honouring the stealth-routing constraint: unauthorized access to admin routes returns 404, not 401 or redirect.


CMS Wave 1 (legacy section header for reference)

Status: All sub-items landed on 2026-05-18.

Goal was: A logged-in collective member can do everything the CLI does today, from a browser.

W1.3 CMS track list

Landed in CMS Wave 3.

/cms/tracks consuming the same GET api/track/page endpoint as the public gallery. Different rendering (table with admin affordances), same VM. No new SQL endpoint.

W1.4 CMS upload endpoint + add page

Landed in CMS Wave 3.

New POST api/cms/track on DeepDrftWeb (auth-gated, see §5 for the transport decision). /cms/tracks/new page wires InputFile to the endpoint. Note: Option B is confirmed — this requires a new POST api/track/upload endpoint on DeepDrftContent (raw WAV in, unpersisted TrackEntity out) in addition to the CMS page and controller.

W1.5 CMS delete endpoint + delete UI

Landed in CMS Wave 3.

New DELETE api/cms/track/{id} on DeepDrftWeb. Removes the SQL row and the vault entry; logs orphans if vault delete fails after SQL delete succeeds. Delete button + confirmation in the list and detail pages.

W1.6 CMS edit endpoint + edit page

Landed in CMS Wave 3.

New PUT api/cms/track/{id} (metadata only — no binary replacement in Wave 1). /cms/tracks/{id} page.


2.4 Web-side track upload

Landed in CMS Wave 1 (subsumed by CMS-PLAN.md).

The CLI is the only producer of tracks today. A web upload UI would pair with TrackService.AddTrackFromWavAsync and the existing PUT api/track/{id} (already [ApiKeyAuthorize]-protected).

  • Why it matters: Lowers the barrier to adding content. The collective can publish without shell access to the host.
  • Shape:
    • New page or modal on the web client, drag-and-drop file input.
    • Upload streams to a POST endpoint on DeepDrftWeb (not DeepDrftContent — the web host orchestrates the dual-write, then forwards bytes to content with the API key it already holds).
    • Authentication: this is the first user-facing action that needs to be gated. A new question — see open question below.
  • Prerequisite: Authentication model for the web side. Currently the site has no user concept. Cookie-with-shared-password? OAuth? Per-collective-member account? Decide before building the UI.
  • Open question: Same as above. This may also bring forward a wider session/identity decision that other features (favourites, listening history) will need eventually.
  • Constraint: Today's dual-write has no compensating rollback — if content-side succeeds and SQL-side fails, the audio is orphaned in the vault. The CLI inherits this; pushing this onto a web upload increases the rate at which orphans can occur. A simple DeadLetterLog of orphaned entryKeys (suggested in the audit) becomes more pressing once the web upload exists.

Phase 0 — Wireframe-driven home page redesign

Status: All sub-items landed on 2026-05-17.

A design wireframe (deepdrft-wireframe.html at the project root) is the source of truth for a full visual reskin of the public site. The current Home.razor is a MudPaper/MudGrid composition with a generic "purple-tint" feature card aesthetic that doesn't match the collective's intended voice. The wireframe replaces it with a layout-first, editorial design: 50/50 hero, frosted-glass nav, dark feature band, green origin/connect split, navy CTA banner with ghost-watermark, and an italic-serif accent treatment throughout.

Scope here is the home page and the chrome that wraps it (nav, layout container, theme palette, font loading). The track gallery (TracksView.razor), the audio player dock (AudioPlayerBar.razor), and the FileDatabase/streaming substrate are out of scope for Phase 0 — they keep working through the existing MudBlazor theme, which is being recoloured under them. The "Now Playing" card in the hero is a new surface that reads from the existing IPlayerService cascade; it is a view onto the player, not a replacement for the dock.

Phase 0 sub-items decompose into worktree-sized tracks. 0.1 is the foundation everything else inherits — land it first. 0.20.4 can proceed in parallel against that foundation. 0.5 is a follow-on tuning pass once the light theme is in.

0.1 Light palette + font system

  • What: Replace the "Charleston in the Day" PaletteLight in DeepDrftWeb.Client/Layout/MainLayout.razor with the wireframe palette (--white #FAFAF8, --navy #0D1B2A, --green #1A3C34, --green-accent #3D7A68, --muted #8A9BB0), expressed as MudBlazor PaletteLight properties. Update the corresponding CSS custom properties in DeepDrftWeb/wwwroot/styles/deepdrft-styles.css so the deepdrft-* utility classes still resolve. Add Geist Mono to the Google Fonts <link> in DeepDrftWeb/Components/App.razor. Upgrade the existing Cormorant link to Cormorant Garamond with the italic + 300/400/600 weight set used by the wireframe. Remove the Bodoni Moda link (and its --font-hero reference) if no remaining surface uses it.
  • Why it matters: Every other Phase 0 sub-item consumes these tokens. Fonts and palette landing first means 0.2/0.3/0.4 can render at intended fidelity from the moment they're built, not approximate-then-correct. The font swap is also the only Phase 0 change that affects HTML served by the host project (App.razor), so isolating it cleanly keeps the render-mode seam clear.
  • Shape:
    • MudBlazor palette mapping (light): Primary = navy, Secondary = green, Tertiary = green-accent, Background = white, Surface = white, AppbarBackground = "rgba(250,250,248,0.88)", AppbarText = navy, TextPrimary = navy, TextSecondary = muted, Divider = "rgba(13,27,42,0.10)", LinesDefault / TableLines to match. Semantic colours (Info/Success/Warning/Error) stay at MudBlazor defaults.
    • Typography block (light): H1H6 and a new wireframe-specific display class use Cormorant Garamond; Button / Default keep DM Sans; introduce a Subtitle1 / Caption family pointing at Geist Mono for label/eyebrow text.
    • CSS variables: rename or alias the existing --deepdrft-primary/--deepdrft-secondary/etc. to the wireframe palette in :root. Add --font-mono: "Geist Mono", monospace; and update --font-hero / --font-headers to "Cormorant Garamond", serif. Where the legacy palette has no wireframe equivalent (e.g. --deepdrft-quaternary warm gold), prefer mapping it to the closest wireframe colour rather than inventing a new one — the goal is convergence on the new vocabulary, not coexistence.
    • Font loading: a single Google Fonts link, ideally one combined request with family=Cormorant+Garamond:ital,wght@…&family=Geist+Mono:wght@…&family=DM+Sans:…. One round-trip, three families.
  • Prerequisite: None — this is the foundation.
  • Constraint: The dark palette ("Lowcountry Summer Nights") must stay functional after this change even if visually mismatched — 0.5 is the dedicated pass for re-harmonising it. Do not edit the dark palette in 0.1. The dark-mode cookie + PersistentComponentState round-trip described in CLAUDE.md must be preserved unchanged.

0.2 Frosted-glass top nav

  • What: Replace the current MudBlazor MudAppBar-based DeepDrftMenu.razor chrome (logo + nav stack + dark-mode toggle, default Material elevation) with the wireframe's fixed frosted-glass nav: 88% opacity off-white background, backdrop-filter: blur(18px), 1px navy-alpha bottom border, no elevation shadow, navy-on-white "Stream Now" CTA pinned right, nav links in Geist Mono uppercase with the muted-to-navy hover transition.
  • Why it matters: The nav sits across every page, so its visual language sets expectations for the rest of the site. The Material elevation + dropdown menu pattern is the strongest "this is a stock MudBlazor app" tell currently; replacing it is the single largest perceived-quality move of Phase 0.
  • Shape:
    • Keep DeepDrftMenu.razor as the file (the existing render-mode wiring and viewport-subscription mobile branch are reused) — rewrite the markup inside it.
    • Wrap a styled <nav> element (or MudAppBar with heavy CSS override) and bind nav links to Pages.AllPages. The link text should render via Geist Mono with the wireframe's letter-spacing and uppercase transform.
    • The "Stream Now" CTA is a new affordance — wire it to /tracks for now (it is functionally a "browse the gallery" action since live streaming isn't a Phase 0 surface).
    • Dark-mode toggle stays — the gas-lamp icon button moves to the right of the CTA. Confirm visual treatment works against both the frosted-white nav (light) and whatever the dark-mode nav becomes after 0.5.
    • Mobile branch: the MudMenu dropdown pattern persists, but the activator + items should adopt Geist Mono and the new colour vocabulary. No drawer.
  • Prerequisite: 0.1 (palette + Geist Mono load).
  • Constraint: The nav is rendered through MainLayout.razor and therefore participates in server prerender. backdrop-filter is CSS-only and renders identically in both passes, so this is safe — but any JS-driven scroll/show behaviour added later must be gated on OnAfterRenderAsync. IBrowserViewportService is already used here for breakpoints and must continue to work after the rewrite. Do not regress the dark-mode toggle wiring (DarkModeCookieService.ToggleDarkModeAsync → cookie → IsDarkModeChanged event up).

0.3 Split hero with live Now-Playing card

  • What: Replace the current centered MudPaper hero in DeepDrftWeb.Client/Pages/Home.razor with the wireframe's 50/50 split:
    • Left: eyebrow ("Charleston, South Carolina"), display title ("Deep / Drft" with italic green emphasis on "Drft"), italic-serif subtitle, body description, and the two CTAs (Start Streaming filled / Browse Tracks ghost). All entering via the existing fade-up CSS animation pattern with staggered delays.
    • Right: dark navy panel with three concentric pulsing rings (CSS keyframe pulse-ring), a frosted "Now Playing" card (label + blinking dot + track title + sub + animated waveform bars), and the stat row (47+ / 2 / ∞).
  • Why it matters: This is the page. Hero is what a first-time visitor sees, and it is the only sub-item that wires the new design back into the live audio system — making the design feel inhabited rather than decorative.
  • Shape:
    • Now-Playing data source: Home.razor consumes [CascadingParameter] IPlayerService Player (cascaded by AudioPlayerProvider from MainLayout). The card binds to Player.IsLoaded, Player.IsPlaying, Player.CurrentTime, Player.Duration. IPlayerService does not currently expose the selected TrackEntity as a public property — add IPlayerService.CurrentTrack { get; } (nullable TrackEntity) and surface the backing field in AudioPlayerService. Additive, no existing consumer is affected — implement it as part of this sub-item without a separate approval gate.
    • Empty state: when Player.CurrentTrack is null, render a placeholder ("Nothing playing — pick a track" or similar) inside the card with the same chrome but no waveform animation. The card is permanent layout, not conditional on selection.
    • Animated waveform bars: Phase 0 uses the wireframe's pure-CSS wave-dance keyframe animation with randomised --h-lo / --h-hi / --dur per bar — driven by no real audio data. A later phase can wire SpectrumAnalyzer data through AudioInteropService.GetSpectrumData() to drive bar heights, but that path is already used by SpectrumVisualizer.razor in the dock and duplicating it here is out of scope.
    • Stat row: static markup with hard-coded "47+", "2", "∞" and TODO comments. The first two could plausibly become real numbers (track count, member count from a future identity model) — flag those at the markup site for Phase 2/identity work to pick up.
    • Pulsing-ring decoration: three absolutely-positioned divs as in the wireframe, with the pulse-ring keyframe. These are decorative and live in deepdrft-styles.css or a Home.razor.css scoped stylesheet — pick scoped CSS for anything home-page-specific to keep the global stylesheet from accreting.
    • Render mode: Home.razor lives in DeepDrftWeb.Client/Pages/, so it is already WASM-interactive end-to-end. The cascading IPlayerService works in both server prerender (no track loaded → empty state) and post-WASM (live state). No OnAfterRenderAsync gymnastics needed.
  • Prerequisite: 0.1 (fonts + palette for the markup to render correctly).
  • Constraint: Do not introduce a second player implementation or a separate state store. The "Now Playing" card is a view onto the same IPlayerService instance the dock uses (see user_one_source_multiple_views). If the dock plays a track, the hero card reflects it; if the hero card eventually grows controls, those calls go through the same cascade. The hero's CTAs route to /tracks and (eventually) trigger Player.SelectTrack from there — they do not become a parallel selection surface.
  • What: Replace the remainder of Home.razor with five wireframe sections in order:
    1. Section divider (The Sound tag between horizontal rules).
    2. Sound section — Genres & Moods label, Every / Frequency / Explored title with italic green emphasis, body copy, 6-column genre grid (House / Techno / Trance / IDM / Progressive / Ambient) with the scaleX-from-left bottom border hover affordance.
    3. Dark features section — navy background, What We Offer label, 4-card feature grid (Lossless Audio Streaming, Live Sessions Broadcast, Studio Video Content, Growing Archive) with stroked SVG icons.
    4. Split origin + connect — green-panel origin copy on the left with a soft-circle decoration, white-panel "Stay Connected" on the right with Newsletter + Live Alerts option rows and a Subscribe Free CTA.
    5. Navy CTA banner with the ghost DRFT watermark, headline, sub, and dual CTAs (Explore the Archive filled-white / View Live Schedule outline-white).
    6. Footer with logo, link list, copyright. Replaces nothing today (there is no footer in the current layout) — add it inside MainLayout.razor so it appears site-wide, or inside Home.razor if Phase 0 wants it on the home page only. Recommend site-wide.
  • Why it matters: These sections are what carries the editorial voice. They are decorative-but-load-bearing — without them, the home page is just a hero floating in whitespace.
  • Shape:
    • Genre grid: static cards. Each genre-card is a Razor markup block (or a small <GenreCard /> component if the duplication grates). Phase 2.2 (album/genre views) will wire these to real filtered routes; for Phase 0, an href="#" placeholder is acceptable, flagged with a TODO: wire to /genres/{slug} in Phase 2.2 comment.
    • Features grid: the four cards mirror the existing copy on the current Home.razor ("High-Quality Streaming", "Live Sessions", "Video Content", "Growing Archive"). Keep the copy intent; reskin to the wireframe. Inline the four SVG icons from the wireframe (they are already 24-box viewBox stroked paths and fit DDIcons.cs if a static-icon home is preferred — but inline is fine for Phase 0; only promote to DDIcons if reuse appears).
    • Origin + Connect split: the origin copy is editorial — adapt the existing "Charleston, SC" copy from the current Home.razor to the new section. The Connect side has two non-functional rows for Phase 0: Newsletter and Live Alerts are decorative pending an identity/subscription system. Flag them.
    • CTA banner: the DRFT ghost watermark uses ::before with a 22rem font size — verify it doesn't trigger layout overflow on narrow viewports (the wireframe uses overflow: hidden on the parent; replicate that).
    • Footer: new site-wide affordance. Site root MainLayout.razor is the right home for it (after MudMainContent, before the closing MudLayout). Use Pages.AllPages for the link list to keep the source of truth in one place.
    • Scoped CSS: these sections are home-page-specific decorative styling. Use Home.razor.css (scoped stylesheet) for anything that doesn't generalise; reserve deepdrft-styles.css for things genuinely shared across pages.
  • Prerequisite: 0.1 (palette + fonts).
  • Constraint: The footer added to MainLayout.razor renders on every page, including /tracks. The dock is the bottom-fixed surface; the footer must be in the document flow above it. Confirmed: the AudioPlayerBar already starts minimized (_isMinimized = true) and expands only on track selection — footer coexistence is acceptable as-is. No suppression logic needed.

0.5 Dark theme harmony pass

  • What: Review the existing "Lowcountry Summer Nights" PaletteDark against the Phase 0 light palette and update it so the dark variant feels like a sibling of the new design vocabulary rather than the old one. The current dark palette is coral/sunset/firefly-gold over deep twilight — that may or may not still read as cohesive once the light side has been pulled to navy/green/off-white.
  • Why it matters: Dark mode is a first-class affordance (cookie-persisted, prerender-aware). If the dark theme reads as a different product after 0.10.4 land, the toggle becomes a surprise rather than a preference. This sub-item is the explicit budget for re-harmonising it instead of letting drift accumulate.
  • Shape: Confirmed: Option B (mirror). Rebuild the dark palette as a dark-navy ground — --navy as background, deeper navy as surface, --green-accent as primary accent, --white (#FAFAF8) as text. Visually consistent with the light theme; the "Lowcountry Summer Nights" coral/sunset identity is retired. Adjust contrast values so text and interactive targets meet WCAG thresholds on the darker ground — the light palette's tokens are a starting point, not a direct copy.
  • Prerequisite: 0.10.4 ideally landed so the harmony evaluation has the actual artefact to look at. Can run in a sketch worktree against 0.1 alone if speed matters.
  • Constraint: The dark-mode cookie + PersistentComponentState round-trip is untouched. Only the palette values in PaletteDark and the .deepdrft-theme-dark CSS-variable block change. Do not refactor the toggle, the cookie service, or the prerender bridge — those are tested and load-bearing.

Phase 0 deferred (not in scope)

These would naturally appear when scoping a redesign, and are explicitly not Phase 0:

  • Real "Now Playing" waveform from SpectrumAnalyzer. CSS-keyframe waveform is good enough for Phase 0. Wiring real spectrum data into the hero card duplicates work already done in the dock and is better folded into a future "shared spectrum hook" refactor.
  • Real stat-row numbers. Track count would need a GET api/track/count endpoint or a count column in the paged response; member count needs an identity model. Hard-coded with TODO is intentional.
  • Genre-filter routes. Genre cards are decorative in 0.4. Real /genres/{slug} is Phase 2.2 work.
  • Subscribe / Live Alerts functionality. Both rows are visual placeholders. Real subscription requires email collection + storage + an identity decision (see "Cross-cutting / not yet themed").
  • TracksView.razor reskin. The gallery has its own composition (TracksGalleryTrackCard) that deserves its own design pass, not a Phase 0 retrofit. It continues to work under the recoloured MudBlazor theme.
  • AudioPlayerBar.razor reskin. Same logic. The dock works against the new palette via MudBlazor tokens; a dedicated dock redesign is out of scope.
  • Animation library / scroll-triggered fades. The wireframe's fade-up is CSS-only with hard-coded delays. Anything richer (IntersectionObserver, framer-motion-equivalent) is post-Phase 0.