# 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 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 `AudioBuffer`s 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.ts` → `wwwroot/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 `MudIconButton`s 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 persistent** — `TrackNumber` (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 `MudCard`s (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 (`@onclick` → `Nav.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 ~L182–188, BlockAlign note ~L225–230) 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.

---

## Phase 2.2 + 2.3 — Album/genre views and gallery search/filter

**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 == 0` → `bytes=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 (1–2s 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 (0–100%); a fixed three-zone gradient (`linearGradient` with `gradientUnits="userSpaceOnUse"`) renders green (0–60% of note height), yellow (60–85%), and orange (85–100%) 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` (0–100). 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.

---

## Track Gallery View Toggle

**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: ~72–88px`, `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 block** — `flex: 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 `MudToggleItem`s (icon-only), or a pair of `MudToggleIconButton`s — `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 1–4 landed on 2026-06-06 (branches `track-card-play-state-wave1`, `track-card-play-state-wave2`, merged to dev).

### Phase 1 — Fix the gallery bug (correctness, smallest viable change)

**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.
- `SpectrumVisualizer` — `BucketCount` 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.razor` — `SpectrumVisualizer` 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 `DeepDrftWeb` → `DeepDrftPublic`

**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.

---

## Phase 2 — Product surface: gallery, browsing, ingestion

### 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 `entryKey`s (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.2–0.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): `H1`–`H6` 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.

### 0.4 Marketing content sections (sound / features / origin+connect / CTA / footer)

- **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.1–0.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.1–0.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 (`TracksGallery` → `TrackCard`) 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.
