# 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 11 — Public Site Enhancements (complete — all tracks 11.A–11.H landed 2026-06-16)
### 11.H — release `EntryKey` identifiers (terminal public-site wave)
**Landed:** 2026-06-16 on dev.
- **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index.
- **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11.
- **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied.
---
### 11.D — Archive filters in the URL
**Landed:** 2026-06-16 on dev.
- **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase.
- **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines).
- **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`.
---
### 11.E — release-level Share
**Landed:** 2026-06-16 on dev.
- **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched.
- **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic.
- **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to ``.
---
### 11.C — retire track-cardinal stack + normalize release cards
**Landed:** 2026-06-16 on dev.
- **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet.
- **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places.
- **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func? HrefResolver { get; set; }` and `[Parameter] public Func? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`.
---
### 11.B — `ReleaseRoutes` resolver + repoint
**Landed:** 2026-06-16 on dev.
- **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C.
- **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting.
- **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id.
---
### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F)
**Landed:** 2026-06-16 on dev.
- **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`.
- **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only.
- **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`.
---
### 11.A — `/cuts/{id}` album-detail page
**Landed:** 2026-06-16 on dev.
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
---
### 11.F — play-queue `IQueueService`
**Landed:** 2026-06-16 on dev.
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable, int)` loads an ordered track list and starts playback.
---
### 11.G — release Description schema slice
**Landed:** 2026-06-16 on dev.
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
---
## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only)
**Landed:** 2026-06-16 on dev.
- **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `
` (no `MudPaper`).
- **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack.
- **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by ``; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css` — `mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor` — `bool ShowHeader = true` gate added around the default header region.
Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`.
---
## CMS Grid Refinements
### `CmsAlbumBrowser` special-action column promotion
**Landed:** 2026-06-15 on branch `cms-special-action-columns`.
Follow-on refinement of 8.C: the `RenderFragment? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell.
- **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList SpecialColumns { get; set; }` (defaulting to `Array.Empty()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `` per declared column (between the Tracks header and the Actions header) and one `` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`).
- **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column.
- **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file.
**Completion note:** `CmsAlbumBrowser.razor` — `RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment Cell)`. `CmsMixBrowser.razor` — `RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks).
---
## Phase 9 — Release Medium Types
### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality
**Landed:** 2026-06-13 on dev.
The single-track-per-release rule for Session/Mix is enforced only in the CMS form layer (the `BatchUpload`/`BatchEdit` master-list collapse, §9.6.B). This wave makes per-medium cardinality a real domain invariant at the upload-service boundary. Full design — the generalised rule, the enforcement-layer trade-offs, the orphan-avoidance reordering, the relationship to the existing rules, and the back-compat reality — lives in `product-notes/phase-9-medium-cardinality-invariant.md`.
- **What:** Promote per-medium track-count from a form convention to a domain invariant enforced at the upload-service boundary. Declare each medium's allowed cardinality as data — `Cut → 1..N`, `Session → 1..1`, `Mix → 1..1` — in a single `ReleaseMedium`-keyed lookup (`MediumRules`, in `DeepDrftModels`), extensible by one entry per future medium. `UnifiedTrackService.UploadAsync` reads the resolved release's medium + live track count and **rejects** a track-add that would exceed the medium's `Max` (only the find path — a freshly created release is always within range). The existing `CountLiveTracksByRelease` (already on `ITrackService`, backs the delete cascade) supplies the count; no new counting primitive.
- **Why:** Daniel ruled single-track-per-Session/Mix a *hard constraint* (§9.5/§9.6, resolved). Today it is form-deep only — the upload endpoint and any scripted ApiKey caller bypass it, and the first-upload-authoritative write path adds a second track to an existing non-Cut release with no check. The data model itself does not forbid what the product forbids. Hardening it at the service layer makes every domain writer pass the rule, closes the gap, and — by declaring cardinality as one shared rule both the form and the service read — guarantees the UI and the domain cannot drift.
- **Shape:**
- **The rule as data.** `MediumRules.CardinalityOf(medium)` returns a `(Min, Max)` value type; no three-arm `switch` in any service. The same lookup the upload service enforces is the one the CMS form collapse reads (refactor `OnMediumChanged` from its hardcoded `medium is Session or Mix` to `MediumRules.CardinalityOf(medium).IsSingleTrack`) — one source, two consumers (form shapes the UI, service enforces the limit), so they cannot diverge. This is a consume-the-new-rule refactor of §9.6.B's landed collapse, **not** a re-litigation of it.
- **Enforcement in the orchestrator, not `TrackManager`.** The check lives in `UnifiedTrackService` (the true boundary for a track-add-to-a-release operation), not the lower-level SQL `Create`. Express the guard generally — `if (liveCount + 1) > cardinality.Max` — so a future bounded-but-not-single medium is covered by the same line.
- **Reorder to avoid orphaning the vault write.** Today `UploadAsync` writes the vault *before* resolving the release. A rejection at that point orphans the audio. Move the cardinality pre-check **before** `AddTrackAsync`: peek the release by `(album, artist)` (a read via the existing `GetReleaseByTitleAndArtistAsync`, not a create), read its medium + count, reject early — then vault-write only the accepted upload. This reordering is part of the wave, not an afterthought.
- **Violation behaviour.** Return a NetBlocks `ResultContainer` failure with a clear message ("A {medium} release holds a single track; '{title}' already has one"). The controller surfaces it as a `409 Conflict` (honest — well-formed request, rule violation) if cheap, `400` otherwise. The CMS already bubbles upload-failure messages inline; no bespoke UI — the common case never reaches the API because the form collapse stops it first, so this is the backstop for the paths the form does not cover.
- **Leave `ReleaseType`-applicability alone.** Do **not** merge the cardinality rule with the `ReleaseType`-only-for-Cut invariant — they are different kinds of rule (count constraint vs. field relevance). They may co-locate as separate named members of `MediumRules`, but no generic "medium invariant engine." Only cardinality is new this wave.
- **Tests.** Extend `MediumWritePathTests` (the §9.5 EF in-memory fixture): Session/Mix reject a second track-add; Cut accepts the Nth; first track on a new Session/Mix succeeds; `MediumRules.CardinalityOf` returns the declared ranges.
- **Acceptance criteria:** A second track-add to an existing Session or Mix release is rejected at `POST api/track/upload` with a clear failure message and no vault orphan; a Cut release accepts many tracks unchanged; the first track on any medium succeeds; the CMS form collapse and the service enforcement both read `MediumRules` (no duplicated cardinality logic); the existing `ReleaseType`-only-for-Cut enforcement is untouched.
- **Back-compat (verified):** No violating data exists — Phase 9 is unmerged, every release migrated to `Cut` (many-track), zero multi-track Session/Mix releases exist. A DB backstop (if chosen, see open question) goes on clean with no data-cleanup migration; the service check has nothing to reconcile. Note honestly: **no** DB-level cardinality or medium constraint exists today (`ReleaseConfiguration` carries only the `(title, artist)` unique index and the `is_deleted` index) — closing that absence is the wave.
- **Open question (Daniel — philosophy call, not pre-empted):** Enforce the cardinality invariant in the **`UnifiedTrackService` domain layer only** (recommended), or *also* add a **Postgres constraint-trigger DB backstop** so a future writer that bypasses the service cannot violate it?
- **Service-only (recommended).** Consistent with the phase's own documented stance — the `ReleaseType`-only-for-Cut invariant chose service enforcement over `HasCheckConstraint` *by choice, not necessity* (`phase-9-release-medium-types.md` §1); cardinality is the same advisory-vs-storage shape and choosing the DB here would split the phase's philosophy. `UnifiedTrackService` is the *only* track-add path today — the "non-CMS caller" still goes through it (`POST api/track/upload`). The bypass a DB backstop defends against (a writer skipping the service entirely) does not exist in the codebase. And the migration is clean either way, so the backstop is free to add *later* if a second writer ever appears.
- **DB backstop (defer).** A partial unique index cannot express this directly (the medium lives on the `release` table, not `track`; Postgres partial predicates can't cross tables). The expressible form is a hand-written PL/pgSQL constraint-trigger EF does not model — a standing maintenance surface. Defensible only if Daniel wants storage-layer immutability over service-layer truth.
- **Recommendation: service-only (C3), defer the DB backstop (C2) as a free-to-add-later option.** This is a decision about where the system's structural truth lives — the service layer vs. the storage layer — not an implementation detail. It is Daniel's to make. Two minor sub-questions ride along (`409` vs `400` status; `MediumRules` in `DeepDrftModels`) — both have clear recommendations and should not block.
**Completion note:** **Decision: C3 — service-layer enforcement only. NO DB backstop, NO migration, NO trigger** was implemented. `MediumRules` (new, in `DeepDrftModels/Enums/`): a `MediumCardinality` record struct (`Allows`, `IsSingleTrack`) + a `CardinalityOf` lookup declaring `Cut = 1..∞`, `Session = 1..1`, `Mix = 1..1` — one declaration, read by both the service and the form. Enforcement in `UnifiedTrackService.UploadAsync`: a general `(trackCount + 1) > cardinality.Max` guard on the find path (existing release), reordered to run as a **read-only peek BEFORE the vault write** so a rejected over-limit upload never orphans audio. The peek uses a new read-only `GetReleaseByTitleAndArtist` on `ITrackService` (returns medium + live count, no create). Violation → NetBlocks failure result, mapped by `TrackController` to **HTTP 409 Conflict** (via a sentinel message marker mirroring the existing `TrackNotFoundMessage`/`NotFound()` pattern). The CMS form collapse predicates (`BatchUpload.OnMediumChanged`, `BatchEdit.OnMediumChanged` + load-path) were refactored to read `MediumRules.CardinalityOf(medium).IsSingleTrack` — form and service now share one source; behaviour unchanged. `ReleaseType`-only-for-Cut enforcement was left untouched. Nine new tests in `MediumWritePathTests`. Accepted residual items (per the C3 stance): a narrow TOCTOU window between peek and create (single-writer stance accepts it), and an integration-test gap on the no-orphan ordering (no vault seam in the EF in-memory fixture). All acceptance criteria met; Wave 7 hardens per-medium cardinality from a UI convention into a real domain invariant.
---
### 9.8 Wave 8 — Remediation (fully landed: 8.A–8.J + 8.L, 8.M, 8.K)
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L); 8.M on 2026-06-14; 8.K on 2026-06-14.
Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete.
**8.A — Release Archive as medium tabs, not cards**
- **What:** Retire the three navigate-away medium cards (`ReleaseArchiveBrowser`); replace with an in-page `MudTabs` strip (`ALL` + one tab per medium) that swaps the grid below in place. Retire the redundant top-level **Releases** toggle item (the `ALL` tab subsumes it).
- **Why:** The card-grid landing required navigation away to reach per-medium grids. Daniel's testing pass identified the correct shape as an in-page tab strip — medium selection without leaving the page.
- **Shape:** `TrackList.razor` renders a `MudTabs` strip when `VM.Mode == BrowseMode.Albums`: the `ALL` panel hosts `CmsAllReleasesGrid` (the 8.B component); per-medium tabs are enum-driven via `Enum.GetValues()` with a `MediumTabLabels` dictionary for display text and a `MediumGrid(medium)` render-fragment `switch` for content (Cut → `CmsCutBrowser`, Session → `CmsSessionBrowser Embedded="true"`, Mix → `CmsMixBrowser Embedded="true"`, fallback `_ =>`). The `/tracks/archive` deep-link route resolves to the Releases/Albums mode via URL inspection in `OnInitializedAsync`. `ReleaseArchiveBrowser.razor` and its `.razor.css` were deleted. `BrowseMode.Archive` was removed from `CmsTrackBrowserViewModel.cs`. New `CmsCutBrowser.razor` (a Cut-filtered grid) derives from `CmsMediumBrowserBase`, `Medium => ReleaseMedium.Cut`. `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained an `[Parameter] public bool Embedded { get; set; }` on the subclass (not on `CmsMediumBrowserBase`, which is untouched); when `true`, standalone page chrome (container, title, "Back to Release Archive" button) is suppressed and only the grid renders; standalone routes keep the chrome. Their §9.5.E per-row Edit and hero/waveform row actions are preserved in both contexts. `/tracks/sessions`, `/tracks/mixes`, `/tracks/archive` remain reachable by direct URL. No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference). Known internally-consistent characteristic: CUTS/SESSIONS/MIXES tabs use the thin `CmsMediumTable` grid (cover/title/artist/edit) while ALL uses the richer `CmsAllReleasesGrid` (expand-tracks/delete/Type-chip); per-medium grid richness deferred to 8.C.
**Completion note:** `TrackList.razor` replaced its former three-way toggle (Tracks / Releases / Release Archive) with a two-item toggle (Tracks / Releases); the Releases arm hosts a `MudTabs` strip with `ALL` (→ `CmsAllReleasesGrid`) and enum-driven medium tabs rendered via `MediumTabLabels` + `MediumGrid` render-fragment switch. `ReleaseArchiveBrowser.razor` and `ReleaseArchiveBrowser.razor.css` deleted. `BrowseMode.Archive` removed from `CmsTrackBrowserViewModel.cs`. New file `CmsCutBrowser.razor` (Cut-filtered, derives from `CmsMediumBrowserBase`, no standalone page route). `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained `[Parameter] public bool Embedded { get; set; }` on the subclass; base class untouched. `/tracks/archive` deep-link resolves to Albums mode. All gate acceptance criteria met; 8.C and 8.E layer onto this foundation.
---
**8.D — Type column chip reads "Session" / "DJ Mix" for non-Cuts**
- **What:** The cross-medium releases grid's Type column must not show a Cut-only `ReleaseType` chip (Single/EP/Album) for Session/Mix rows. For non-Cut media the chip reads the medium name — **"Session"** or **"DJ Mix"**.
- **Why:** The CMS Release Archive grid and the `ALL`-tab grid show all releases together. When a Session or Mix row renders a Cut-only `ReleaseType` value, the UI contradicts the medium taxonomy — a Session row should read "Session," not "Single/EP/Album."
- **Shape:** The Type cell was rendering `@context.Release.ReleaseType` unconditionally. Per Phase 9 read-model design, `ReleaseDto.ReleaseType` is nullable and nulled for non-Cut media at the mapping point. The cell becomes medium-aware: when `Medium == Cut`, show `ReleaseType`; otherwise show the medium's display name from a lookup (no hardcoded switch — a future medium's label comes free from the enum + lookup entry).
- **Acceptance criteria:** Cut row's Type chip shows Single/EP/Album; Session row shows "Session"; Mix row shows "DJ Mix"; no row shows a Cut-only `ReleaseType` for a non-Cut medium.
**Completion note:** The Type cell in `CmsAlbumBrowser.razor` was refactored to a single ternary: when `Medium == Cut`, renders `ReleaseType?.ToString() ?? "—"` (reusing the existing em-dash empty-cell idiom used by Genre and Release-Date cells); otherwise renders from `private static readonly IReadOnlyDictionary MediumTypeLabels` with entries `[ReleaseMedium.Session] = "Session"` and `[ReleaseMedium.Mix] = "DJ Mix"`. Dictionary name is **MediumTypeLabels**. A `@using DeepDrftModels.Enums` was added. Future non-Cut media require exactly one new dictionary entry — no markup change. Acceptance criteria met; Type column now correctly shows "Session" / "DJ Mix" for non-Cut rows.
---
**8.B — `ALL` tab: all-releases grid with edit**
- **What:** The left-most `ALL` tab shows the current cross-medium releases grid (every release, all media) with working edit buttons — the surface the retired **Releases** toggle used to show.
- **Why:** The CMS Release Archive needed a unified view of all releases as a foundation for the tab-strip redesign (8.A). The grid already existed in `CmsAlbumBrowser`; 8.B makes it the `ALL` tab's content.
- **Shape:** `CmsAlbumBrowser` displays the cross-medium releases grid with sort, delete (cascade + orphaned-release cleanup), expand-tracks, and per-row edit, all unchanged. The grid self-loads via `ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`, with an optional `[Parameter] public EventCallback OnReleasesChanged` for host cache invalidation (set in `TrackList.razor` for genre-cache sync). A single `ReloadAsync` path serves both initial load and post-delete refresh.
**Completion note:** `CmsAllReleasesGrid.razor` (new, in `DeepDrftManager/Components/Pages/Tracks/`) wraps `CmsAlbumBrowser` as a self-loading component. Component owns its data load (`ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`), renders `CmsAlbumBrowser` internally, and refreshes after delete via `ReloadAsync()`. `OnReleasesChanged` callback parameter (optional, safe no-op when unset) lets a host invalidate sibling caches on mutation — `TrackList.razor` `BrowseMode.Albums` now renders `CmsAllReleasesGrid` and passes `OnReleasesChanged` so the genre cache still invalidates on release delete. `CmsTrackBrowserViewModel.cs` was trimmed: the now-redundant album load/cache (`Albums`/`AlbumsLoading`) was removed; `Invalidate()` narrowed to genre-only. `CmsAlbumBrowser` unchanged — sort, delete cascade, expand-tracks, per-row edit, Type chip (per 8.D) all preserved. No `@rendermode` override, no constructor growth, no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit/no DeepDrftManager reference; the underlying `GetReleasesAsync` data path is covered by existing tests). Files: `CmsAllReleasesGrid.razor` (new), `TrackList.razor`, `CmsAlbumBrowser.razor`, `CmsTrackBrowserViewModel.cs`. Acceptance criteria met; `ALL` tab grid with edit now live as an embeddable component, clearing the foundation for 8.A tab strip.
---
**8.F — Session hero image in the upload form (retire the two-step)**
- **What:** Compose the hero-image field into the Session upload form so a Session is authored in one pass; remove the "set it later from the browser" alert. Hero is **optional but warns if missing** (no hard gate).
- **Why:** Sessions need their signature hero image. Requiring a post-upload trip to the Session browser is a friction point in the authoring flow. Embedding the hero upload in the creation form (mirroring the deferred cover-art `` UX) lets an admin author a complete Session in one submission.
- **Shape:** `SessionFields.razor` renders a deferred hero-image `` (mirroring the cover-art deferred-upload UX), but **only `@if (AllowHeroUpload)`** — a new bool parameter. `AllowHeroUpload` is threaded `BatchUpload → AlbumHeaderFields → MediumFields → SessionFields` (same chain as the `HeroImageFile`/`HeroImageFileChanged` pair). It defaults `false`; only `BatchUpload` passes it `true`. On the edit forms (`BatchEdit`, `TrackEdit`, `TrackNew`) it stays false, so they show a `Severity.Info` guidance alert pointing to the Sessions browser per-row replace — no dead control. On submit, `BatchUpload` creates the release via the existing upload path, then POSTs the held hero file to the existing resource-addressed `POST api/release/{id}/session/hero-image` using `result.Value.ReleaseId`. Hero is optional with a non-blocking warn-then-proceed gate: a first Session submit with no hero shows a `Severity.Warning` message (`_warningMessage`) and primes acknowledgment; a later submit proceeds. The null-`ReleaseId` edge logs + Snackbars instead of dropping the file silently.
- **Acceptance criteria:** Session upload form shows a hero-image `` alongside the cover art; hero upload optional (warning-then-proceed gate); edit forms show guidance alert instead of the hero field; per-row hero upload in `CmsSessionBrowser` unchanged; no sessions uploaded without hero field available.
**Completion note:** `SessionFields.razor` gained `[Parameter] public bool AllowHeroUpload { get; set; }` and wraps hero-image `` in `@if (AllowHeroUpload)`. Hero image input shows only in upload form, suppressed in edit forms with guidance alert (`Severity.Info` routing to Sessions browser) visible instead. `AllowHeroUpload` parameter threaded through `MediumFields.razor → AlbumHeaderFields.razor → BatchUpload.razor` (set `true` only in `BatchUpload`; defaults `false`). `BatchUpload.razor` holds hero file in a field (`private IBrowserFile? _heroImageFile`) assigned by `SessionFields`'s `HeroImageFileChanged` callback, then POSTs held file to `POST api/release/{id}/session/hero-image` after successful release creation using `result.Value.ReleaseId`. Hero optional with non-blocking gate: `Severity.Warning` on first submit without hero, primes boolean; second submit proceeds (warning dismissed). Null `ReleaseId` edge case logs error + Snackbar instead of silently dropping file. Per-row hero upload in `CmsSessionBrowser` untouched (remains the replace/correct path). Files: `SessionFields.razor`, `MediumFields.razor`, `AlbumHeaderFields.razor`, `BatchUpload.razor`. Acceptance criteria met; hero image now composable in upload form with optional-but-warn semantics.
---
**8.G — "Album Name" → "Release Name" label**
- **What:** The `AlbumHeaderFields` form's first-field label reads **"Release Name"**, not "Album Name."
- **Why:** The field now covers Cuts, Sessions, and Mixes — not just albums. "Release Name" is the accurate noun.
- **Shape:** Rename `Label="Album Name"` → `Label="Release Name"` and the `RequiredError` string in `AlbumHeaderFields.razor`. Check placeholder/help text for consistency.
- **Acceptance criteria:** The first field of the release header form reads "Release Name"; the required-validation message references "Release Name."
**Completion note:** `AlbumHeaderFields.razor` `Label` and `RequiredError` changed "Album Name" → "Release Name". Matching validation message strings in `BatchEdit.razor` and `BatchUpload.razor` were updated to "Release Name is required" for consistency. Three files total; trivial rename, acceptance criteria met immediately.
---
**8.J — ARCHIVE popover click does not close (bug)**
- **What:** Clicking a popover child leaves the pure-CSS hover dropdown stuck open on SPA navigation. The desktop ARCHIVE menu (a hover-triggered `.dd-nav-dropdown`) has no JS dismissal — it hides only when cursor leaves or focus moves out. After enhanced SPA nav (Blazor keeps the DOM), the cursor often remains over the parent, so the dropdown stays visible.
- **Why:** Dead affordance. An admin clicks "Sessions" in the dropdown, the nav updates in-place, and the dropdown stays floating over the new content, blocking clicks. Dismissal must be explicit (JS-based, not CSS-only).
- **Shape:** Detect SPA navigation and trigger a dismissal handler. The existing `DeepDrftMenu.razor` / `DeepDrftMenu.razor.css` structure carries `.dd-nav-dropdown` with `:hover` and `:focus-within` CSS triggers. A JS `DismissDropdown()` function or a Blazor `@onmouseleave` handler on the parent can close the dropdown imperatively after nav. Coordinate with 8.I: if 8.I flattens the nav and removes the popover entirely on desktop (the three media become inline appbar items), the dismissal logic only survives on breakpoints/sub-menus where a popover remains. Fix applies where the popover still exists.
- **Acceptance criteria:** Clicking a popover child (e.g. "Sessions") closes the dropdown; no dropdown floats after SPA navigation. Desktop and mobile both dismiss correctly.
**Completion note:** `DeepDrftMenu.razor.css` updated with a new `.dd-nav-item-collapsed` rule (scoped `.dd-nav-item-parent.dd-nav-item-collapsed .dd-nav-dropdown`) using `!important` to override both the `:hover` and `:focus-within` show rules. Razor state: collapse tracked in `private readonly HashSet _collapsedDropdowns = []` keyed by `navPage.Route`; parent `
` gets the class via `_collapsedDropdowns.Contains(navPage.Route)`. Child link's `@onclick` calls `CollapseDropdown(navPage.Route)` (adds route to set); parent `
`'s `@onmouseleave` AND `@onfocusout` both call `ResetDropdown(navPage.Route)` (removes it). Per-parent keying enables multiple independent dropdowns; `@onfocusout` reset lets keyboard users re-enable dropdown without mouse pass. Mirrors existing `CloseMobileMenu` pattern. The dropdown no longer floats after SPA navigation; acceptance criteria met.
---
**8.L — Consolidate release name + track name for single-track releases**
- **What:** For single-track media (Session and Mix), the UI presents **a single name** (Release Name). The track name is **derived from it automatically** on save and kept synced — the admin never enters or sees a separate "Track Name" field. Cuts (multi-track) remain unaffected (separate release and per-track names). This is a consolidation: today these forms surface *two* name inputs for media with only one logical name.
- **Why:** A Session or Mix is a single work with one name. Surfacing a separate "Track Name" invites divergence (release "Lowcountry Live #3" whose track is "untitled-master-final") and a confusing authoring experience. The name consolidation removes that redundancy.
- **Shape:** On **create** (`BatchUpload`, single-track medium): the form presents one name field (Release Name, via 8.G rename); no separate Track Name input. On save, `_tracks[0].TrackName` is set equal to the Release Name. On **edit** (`BatchEdit`, single-track medium): the form presents one name field (Release Name); the per-row Track Name editor is suppressed (via a flag passed to `BatchTrackDetail`). On save, the track's `TrackName` is set equal to the (possibly edited) Release Name — they stay synced. Switching the medium selector mid-form re-drives which name fields are visible (one name for Session/Mix; release + per-track names for Cut) without losing entered data.
- **Acceptance criteria:** Single-track (Session/Mix) **create** path shows one name field (Release Name) with no separate Track Name input; on save, `TrackName == ReleaseName`. Single-track **edit** path shows one name field (Release Name); switching to Cut shows both; the form does not lose entered data on selector change. Track name stays synced with release name on edit (changing Release Name updates the track name). Cuts (multi-track) unaffected — Release Name and per-track Track Names are distinct. Legacy `TrackNew`/`TrackEdit` forms are **out of 8.L scope** (their retirement is 8.M). No public-site changes needed (public detail/gallery views already key off release title only).
**Completion note:** `BatchTrackDetail.razor` gained `[Parameter] public bool ShowTrackName { get; set; } = true;` and wraps Track Name `` in `@if (ShowTrackName)`. `BatchUpload.razor` removes Track Name input on single-track branch, sets `_tracks[0].TrackName = _albumName` in `SubmitAsync` (after non-empty `_albumName` validation, before upload loop). `BatchEdit.razor` passes `ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"` to `BatchTrackDetail` and syncs `_tracks[0].TrackName = _albumName` in `SaveAsync`. The "is single-track" decision is driven by shared `MediumRules.CardinalityOf(_medium).IsSingleTrack` declaration (same one used by upload service and §9.7 invariant) — not a hardcoded Session/Mix check. Default `true` keeps Cut path and BatchUpload's Cut branch (passing no `ShowTrackName`) showing the field. No `MudForm`/`EditForm` wrapper exists, so hiding the field has no validation-deadlock effect. Single-track forms now present one name, consolidating two-field redundancy; Cuts unaffected with Release Name and per-track names distinct. Acceptance criteria met; Wave 8 track 8.L consolidates form UX to match single-track-per-medium design intent.
---
**8.C — Per-medium grids gain working edit affordances (full parity with ALL tab)**
- **What:** Cut / Session / Mix tab grids gain full parity with the ALL tab: expand-tracks, delete, Type chip, and per-row Edit action — the same rich `CmsAlbumBrowser` grid the ALL tab uses, filtered to each tab's single medium.
- **Why:** The initial 8.A landing acknowledged that the per-medium tabs used the thin `CmsMediumTable` (cover/title/artist/edit) while ALL used the richer `CmsAlbumBrowser`; 8.C was the deferred parity track. Per-medium grids differing from the ALL grid in affordances was confusing and inconsistent.
- **Shape:** Daniel decided option (b) — full parity. Each per-medium browser (`CmsCutBrowser`, `CmsSessionBrowser`, `CmsMixBrowser`) now renders `CmsAlbumBrowser` filtered to its single medium. `CmsAlbumBrowser.razor` gained one optional `[Parameter] public RenderFragment? RowActions` slot, rendered in the Actions cell before the shared edit/delete buttons; the ALL tab leaves it unset and is unchanged. `CmsMediumBrowserBase.cs` was refactored: it now feeds the rich grid a medium-filtered `Releases` projection (`IReadOnlyList`) alongside `ReloadAsync` (wired to the grid's post-delete `OnReleasesChanged`) and a `RowFor(release)` lookup (`_rowsById` dictionary keyed by `release.Id`) for per-medium action-state recovery by the `RowActions` fragment. Session hero and Mix waveform row actions are preserved via each browser's `RowActions` content. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` were deleted (now orphaned). No `TrackList.razor` change (the `MediumGrid` switch renders the same component identifiers). No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (no bUnit harness; medium-filter data path covered by `ReleaseBrowseQueryTests`). Files modified: `CmsAlbumBrowser.razor`, `CmsMediumBrowserBase.cs`, `CmsCutBrowser.razor`, `CmsSessionBrowser.razor`, `CmsMixBrowser.razor`, `CmsSessionBrowser.razor.css`; deleted: `CmsMediumTable.razor`, `CmsMediumTable.razor.css`.
**Completion note:** `CmsAlbumBrowser.razor` gained `[Parameter] public RenderFragment? RowActions { get; set; }` rendered in the Actions cell (before edit/delete) via `@RowActions?.Invoke(context.Release)`; the ALL tab's `CmsAllReleasesGrid` wrapper passes nothing, leaving ALL unchanged. `CmsMediumBrowserBase` (generic, abstract) was rewritten: it now loads a medium-filtered release list via `ICmsReleaseService.GetPagedAsync`, projects to a bare `IReadOnlyList Releases` for the rich grid, maintains `_rowsById` for action-state recovery via `RowFor(release)`, and exposes `ReloadAsync()` wired to the grid's `OnReleasesChanged`. `CmsCutBrowser`, `CmsSessionBrowser`, and `CmsMixBrowser` were updated to render `CmsAlbumBrowser` (instead of the now-deleted `CmsMediumTable`) with their medium-specific `RowActions` fragment. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` deleted. Per-medium tabs now render the same expand-tracks / delete / Type-chip / edit grid as the ALL tab, single-sourced. Acceptance criteria met; Wave 8 track 8.C brings per-medium grids to full parity with the ALL tab.
---
**8.E — Add-Track buttons in all modes, medium-aware routing**
- **What:** Every Release Archive tab surfaces an Add Track button that routes to the upload page pre-set to that tab's medium. The ALL-tab Add Track defaults to Cut; the medium selector stays user-changeable after landing on the form.
- **Why:** Before 8.E, the upload form had no direct link from the Release Archive tabs. An admin starting from the Sessions tab had no in-context Add Track button pointing at a Session upload.
- **Shape:** `TrackList.razor` gained a `MudStack` Add Track button above `MudTabs` in the `Albums` browse arm (§8.A's tab strip), `@bind-ActivePanelIndex="_activeTabIndex"`, and two helpers: `ActiveMedium` maps tab index 0 (ALL) → `ReleaseMedium.Cut` and index ≥1 → `Enum.GetValues()[index-1]`; `AddTrackHref(medium)` → `/tracks/upload?medium={medium.ToString().ToLowerInvariant()}`. `BatchUpload.razor` reads `?medium=` via `[SupplyParameterFromQuery(Name = "medium")]`, parses with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined`, defaults to `ReleaseMedium.Cut`, and routes through the existing `OnMediumChanged` so the pre-selected medium drives the conditional fields on load (the 8.F hero field for Session, `ReleaseType` for Cut) and the 8.L single-track name-collapse runs identically to a user change. The selector stays user-changeable after landing; `/tracks/upload` with no param still defaults to Cut. No `@rendermode` override; no constructor growth; no `IServiceProvider`. `TrackList.razor` edits confined to the tab-strip toolbar (no grid-component / `MediumGrid` switch edits). Files modified: `TrackList.razor`, `BatchUpload.razor`.
**Completion note:** `TrackList.razor` gained `_activeTabIndex` backing field with `@bind-ActivePanelIndex`, `ActiveMedium` computed property (index 0 → `Cut`; index ≥1 → `Enum.GetValues()[index-1]`), `AddTrackHref(medium)` static helper producing `/tracks/upload?medium={…}`, and a `MudStack` row above `MudTabs` rendering the medium-aware Add Track button. `BatchUpload.razor` gained `[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }` and seed logic in `OnInitializedAsync`: if `MediumParam` is set, `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` gate the call to `OnMediumChanged(medium)`, driving conditional fields and 8.L name-collapse on load without requiring a user gesture. The query-param convention is new to the codebase as a Blazor `[SupplyParameterFromQuery]` entry, mirroring the existing API-side `Enum.TryParse`/`IsDefined` parse posture. Acceptance criteria met; Wave 8 track 8.E surfaces a medium-aware Add Track button in every Release Archive tab.
---
**8.H — Archive page becomes the searchable all-releases browser (release-cardinal, decided H2)**
- **What:** Replace the public `/archive` three-card overview with a release-cardinal searchable browser over all releases. Retire the three-card overview on every breakpoint; cascade: `/tracks` (`TracksView`) is demoted from the nav (route kept reachable); mobile ARCHIVE → the new browser.
- **Why:** The three-card overview is dead weight — it merely summarizes what the site offers without letting the user interact with actual content. The real archive experience is discovering and exploring releases across all media with search, filtering, and per-medium detail pages. A searchable all-releases browser is what "archive" means to a listener.
- **Shape:** New `ArchiveView` (`.razor` + `.razor.cs` + `.razor.css`): debounced Title/Artist search, an enum-driven medium filter (`All` + per-medium from `Enum.GetValues()` + a label lookup, so a fourth medium surfaces from one entry), and a genre filter sourced from the existing distinct-genres list. Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}` (the established `AlbumsView` Cut destination, since Cuts have no single-release detail page). The unfiltered first page is bridged across the prerender→WASM seam via `PersistentComponentState` (keyed `"archive-releases"`, persisted/restored only when no filter is active), matching the `TracksView`/`AlbumsView` pattern. No page-level `@rendermode` override. **API surface grew (additive, backward-compatible):** new `ReleaseFilter` DTO (`SearchText`, `Genre`, `IsEmpty`) mirroring `TrackFilter`; `q` + `genre` query params threaded through `ReleaseController` → `ReleaseProxyController` → `ReleaseClient`/`IReleaseDataService`/`ReleaseClientDataService` and `ReleaseManager`/`IReleaseService`/`ReleaseRepository.GetPagedByMediumAsync`. Search uses parameterized `EF.Functions.ILike` over Title/Artist (Npgsql); genre is exact-match. No constructor growth, no `IServiceProvider` — optional params on existing signatures. New test `ReleaseBrowseQueryTests` covers the repository query path (medium/genre/compose/null-passthrough/soft-delete; the `ILike` search is a Postgres-DSN-gated integration test that skips without a DB).
- **Acceptance criteria:** `/archive` is a searchable, filterable all-releases browser with debounced search, medium and genre filters; cards navigate to correct per-medium detail routes; unfiltered first load is prerendered and bridged via persistent state; existing `/tracks` route stays reachable but is removed from public nav; no three-card overview remains.
**Completion note:** `ArchiveView` (at `/archive`) rewritten in place from the three-card overview to a release-cardinal searchable browser. New `ArchiveView.razor`, `ArchiveView.razor.cs`, `ArchiveView.razor.css` implemented with debounced search (Title/Artist), medium filter (enum-driven, no hardcoded switch), and genre filter (sourced from distinct-genres list). Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}`. Unfiltered first load persisted/restored via `PersistentComponentState` keyed `"archive-releases"` (following `TracksView`/`AlbumsView` pattern). New `ReleaseFilter` DTO added with `SearchText`, `Genre` (string, optional), `IsEmpty` (bool). `ReleaseController` extended with `q` and `genre` optional query params on `GetPagedByMedium` endpoint; params threaded to `ReleaseProxyController` and down through data-service layers. Repository method refactored: `GetPagedByMediumAsync` now accepts optional `searchText` and `genre` parameters, applies parameterized `EF.Functions.ILike` for search over Title/Artist (Npgsql), exact-match for genre. New integration test `ReleaseBrowseQueryTests` covers medium filter, genre filter, compose, null passthrough, soft-delete; `ILike` search integration-only, skips without Postgres DSN. Old `/archive` three-card overview removed. API surface backward-compatible (all new query params optional, existing `medium` filter unchanged). Navigation structure unchanged; `/tracks` (`TracksView`) remains in nav and routable (demotion from nav, and removal of GENRES, are explicit work items for track 8.I). Three-card overview fully retired; public archive is now the searchable all-releases browser; acceptance criteria met.
---
**8.I — Nav slimmed: ARCHIVE + three medium modes inline, GENRES removed**
- **What:** Above the medium breakpoint the appbar carries ARCHIVE (the new release-cardinal browser) and the three medium modes (CUTS/SESSIONS/MIXES) as direct inline links. GENRES removed from the nav. `/tracks` (`TracksView`) demoted from the nav (route kept reachable).
- **Why:** The nav was cluttered with redundant levels (ARCHIVE popover + separate Tracks/Genres entries). Flattening the medium links into the appbar alongside ARCHIVE streamlines navigation; removing GENRES (while keeping the route) reduces clutter. The real archive is release-cardinal (8.H); the `/tracks` track-cardinal gallery is no longer the primary public browse surface.
- **Shape:** `Pages.cs` `MenuPages` removes GENRES and `Tracks` entries; keeps ARCHIVE (now linking to the searchable all-releases browser per 8.H) with no children in the menu model (the three media become inline appbar siblings). `DeepDrftMenu.razor` flattens ARCHIVE and the three medium items into inline `` siblings above the `sm` (600px) breakpoint; the mobile renderer keeps ARCHIVE with the three media indented in the hamburger drawer. The desktop hover popover (`.dd-nav-dropdown`, `:hover`/`:focus-within` CSS, dead-code collapse/reset machinery from 8.J) is removed as now-dead code — no desktop popover renders at any width ≥600px, and the only surviving popover surface (mobile drawer) already dismisses on child click via `CloseMobileMenu`. Code review verified: no desktop popover regression at any breakpoint, mobile drawer dismiss unchanged.
**Completion note:** `Pages.cs` `MenuPages` trimmed: **Tracks** and **Genres** entries removed; ARCHIVE retains its three medium children (Cuts/Sessions/Mixes) unchanged as the single nav data shape — no duplication or child nulling. `/tracks` and `/genres` routes remain reachable by direct URL. `PageRoute.HasChildren` is now unreferenced but left in place. `DeepDrftMenu.razor` refactored: above `sm` breakpoint the renderer builds a flat `