From 913861860b26648a3dd05b182d0f88ad15f2ab27 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:09:16 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20shape=20Phase=2011=20=E2=80=94=20?= =?UTF-8?q?Public=20Site=20Enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Phase 11 to PLAN.md and a full design spec under product-notes: Cuts gain a /cuts/{id} album detail page; release-title click resolves medium to a dedicated detail page; redundant /tracks?album view retired; Archive filters move into the URL. Includes gap analysis and open questions for Daniel. --- PLAN.md | 23 + .../phase-11-public-site-enhancements.md | 551 ++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 product-notes/phase-11-public-site-enhancements.md diff --git a/PLAN.md b/PLAN.md index 02c399d..4ebac27 100644 --- a/PLAN.md +++ b/PLAN.md @@ -192,6 +192,29 @@ Full design, renderer architecture, the four effects, acceptance criteria, and p --- +## Phase 11 — Public Site Enhancements + +The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **make the release the cardinal unit of every public navigation, and make every navigation an addressable, shareable URL.** Four Daniel commitments — two structural (a Cuts detail page; the release-title click resolves medium → dedicated detail page), two normalization/polish (retire the redundant `/tracks?album` view; encode Archive filters in the URL). Full design, framing corrections, wave decomposition, and the gap analysis: `product-notes/phase-11-public-site-enhancements.md`. + +**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). The two real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}` — the track gallery filtered), and **`/archive` holds its filters in component fields, not the URL**. + +**Three framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`, and the player-bar title links there. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** applied at click sites (the player bar already carries the release id + medium, so no round-trip is needed), plus a thin `/tracks/{id}` redirect page for bare-release-id deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern verbatim — not a new browser. + +**Design discipline.** The medium→route resolver is **one table** (promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes` helper) consumed by the player bar, Archive cards, and Cuts cards — not three switches (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + new `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3's named rule: layout variance rides a slot, never a flag). The Archive URL-binding makes the URL the source of truth (history-driven re-fetch in `OnParametersSet`), so back/forward + shareable links fall out structurally. + +Sequenced as four tracks across two parallel waves. + +- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list with per-row play, header Play → track 1. New `CutDetailViewModel`; reuses `GetById` + the existing `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's Cut resolution.** +- **11.B — medium→route resolver + repoint.** Shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title, Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.** +- **11.C — normalization / reduction.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both consumers). Decide with Daniel whether to retire the whole track-cardinal stack (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`) now that Cuts have a proper detail page and `/tracks` is already nav-demoted — the phase's biggest reduction lever. Consolidate duplicate medium-label lookups. **Depends on 11.B.** +- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`. Fully independent — touches only `ArchiveView`. **No dependency; free-floating.** + +**Dependency shape:** `11.A → 11.B → 11.C`; **11.D parallel**. + +**Open questions (Daniel — in the spec §7):** player-bar title now points at release detail not track detail (recommend yes — it is the premise of req 1); `/cuts/{id}` scaffold strategy (generalized `Header` slot vs. bespoke page); Cut header affordance idiom (icon vs. labeled buttons); **track ordering** — `TrackEntity` has no `TrackNumber` column, so album order is insertion/`Id` order today (recommend ship on insertion order, raise `TrackNumber` as its own small phase only if needed — `[do not assume into Phase 11]`); `/tracks` retirement scope (album-branch only vs. whole track-cardinal stack). **Adjacent gaps surfaced, not in scope:** the Cuts album page is the strongest case yet for the deferred **play queue** (`§1.3`) — "play album" is the expected affordance; design header Play so a future enqueue-album is a handler swap, not a rewrite. `SharePopover` is track-keyed — a Cut header Share arguably shares the **release** URL, a new share target. + +--- + ## Working with this file - **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing. diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md new file mode 100644 index 0000000..1f6d6d9 --- /dev/null +++ b/product-notes/phase-11-public-site-enhancements.md @@ -0,0 +1,551 @@ +# Phase 11 — Public Site Enhancements + +Status: spec / design. Author: product-designer. Date: 2026-06-15. +**Plan only — no code edits made by this doc.** + +Cross-references: `PLAN.md §11` (the concise phase entry), `product-notes/phase-9-release-medium-types.md` +(the medium model — `ReleaseMedium` enum, the per-medium detail-page strategy, the +`ReleaseDetailScaffold` contract), `COMPLETED.md §9.8` (Wave 8 remediation — the `/archive` +release-cardinal browser, the inline nav, the `TracksView` demotion), memory *One source, multiple +views* and *Design for adaptability up front*. + +--- + +## 0. Why this phase exists, and the state it inherits + +Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface. +Wave 8 then remediated the public side: `/archive` became a release-cardinal searchable browser, +the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tracks` gallery was +demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14). + +Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use +surfaced four commitments. They share one spine: **make the release the cardinal unit of the public +site, and make every navigation an addressable, shareable URL.** Two of the four are structural +(a Cuts detail page; `/tracks` collapses to a router); two are normalization/polish (retire the +`/tracks?album` view; encode Archive filters in the URL). + +This is not a greenfield phase — most of the scaffolding it needs already exists. The job is +**closing the one asymmetry Phase 9 left** (Cuts are the only medium with no single-release detail +page) and **finishing the URL-addressability story** the Archive browser started. + +### What already exists (verified against live source, 2026-06-15) + +| Surface | State | File | +|---|---|---| +| `/sessions/{id}` detail | **Exists, mature.** Hero-dominant overlay; bridged prerender; play wiring. | `Pages/SessionDetail.razor` | +| `/mixes/{id}` detail | **Exists, mature.** Full-page WebGL waveform background + scaffold; bridged prerender. | `Pages/MixDetail.razor` | +| `ReleaseDetailScaffold` | **Exists.** Invariant trio (back link, masthead, play/share) + `Hero`/`MetaContent` slots. Composed by `TrackDetail` and `MixDetail`. | `Controls/ReleaseDetailScaffold.razor` | +| `ReleaseDetailBase` | **Exists.** Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via `releaseId`-filtered track page). | `Pages/ReleaseDetailBase.cs` | +| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. | `Pages/AlbumsView.razor` | +| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium. **Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.cs)` | +| `/track/{EntryKey}` detail | **Exists** as `TrackDetail` — the *track-cardinal* detail the player-bar title links to. | `Pages/TrackDetail.razor` | +| `/albums` | **Exists** as a permanent redirect → `/cuts`. | `Pages/AlbumsRedirect.razor` | + +### Three framing corrections (the brief's vocabulary vs. the live routes) + +The brief's framing is directionally right but uses route names that do not match the code. Naming +these up front so the implementer is not misled: + +1. **There is no `/tracks/{id}` route.** The track-cardinal detail route is **`/track/{EntryKey}`** + (`TrackDetail`), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar + title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). When the brief says + "`/tracks/{id}` becomes a pure router," the *intent* is: **the thing a release-title click lands + on should resolve medium → the correct dedicated detail page.** See §2 for what this actually + means against the live routes — the brief's "router" is better realized as a small **medium→route + resolver** than as a literal `/tracks/{id}` page, because the player bar carries a track, not a + release id. + +2. **Cuts genuinely have no single-release detail page.** This is the real asymmetry. `/cuts` + (`AlbumsView`) cards and `/archive` Cut cards both open `/tracks?album={title}` — the track + gallery filtered to the album title. Sessions and Mixes route to dedicated `/sessions/{id}` / + `/mixes/{id}` pages. The new `/cuts/{id}` page (§3) closes this gap and is the heart of the phase. + +3. **`/archive` filters are already in component state but not the URL.** `ArchiveView` holds + `_selectedMedium`, `_selectedGenre`, `SearchText` as private fields with no + `[SupplyParameterFromQuery]` and no `NavigateTo` on filter change. Requirement 4 is therefore a + **URL-binding pass over an existing browser**, not a new feature. The pattern to borrow already + lives next door: `TracksView` reads `?album=`/`?genre=`/`?q=` via `[SupplyParameterFromQuery]` + (`TracksView.razor.cs` lines 21–23). + +--- + +## 1. The four commitments (Daniel, faithful capture) + +1. **`/tracks/{id}` becomes a pure router** → resolve a release's `ReleaseMedium` to the correct + dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Hit only when a user + clicks the release title in the player bar; its sole job is medium → page resolution. + +2. **New `/cuts/{id}` page — album view.** + - Header section, **left-aligned**: release name, artist, genre, release year, plus **Play** and + **Share** buttons. + - Cover art **large-ish, right**, with a **theme border** around the image. + - Below: an **ordered track list**, each row with a play button. + - Header Play starts the **first track**. + +3. **Code normalization / reduction.** Eliminate the now-redundant `/tracks?album` page view (the + Archive provides all filtering/searching, so `/tracks` collapses to a pure router). Identify and + normalize other UI redundancy. + +4. **Archive search params in the URL.** Search term, medium filter, genre filter all encoded in + `/archive?…` so filter actions create navigable history anchors (back/forward, shareable links). + +--- + +## 2. Requirement 1 reframed — the medium→detail resolver + +**Recommendation: do not build a literal `/tracks/{id}` page. Build a `medium → detail route` +resolver and apply it at the two click sites that need it.** Here is why, and the three shapes +considered. + +### What the player-bar title actually carries + +The player bar holds a **`TrackDto`** (`TrackMetaLabel.razor`). A `TrackDto` carries a nested +`Release` (with `Id` and `Medium`). So at the click site we already know the release id *and* the +medium — there is no need for a round-trip "router page" that fetches the release to discover its +medium. The resolver is a pure function: + +``` +Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}" +``` + +### Three shapes + +- **(i) Literal `/tracks/{id}` router page.** A routable component that fetches the release by id, + reads `Medium`, and `NavigateTo`s the dedicated page (`replace: true` so the router never sits in + history). *Cost:* a network round-trip and a flash of an empty page on every release-title click, + to rediscover a medium the caller already knew. Justified **only** if some entry point has *only* + a release id and not the medium (e.g. an external deep link to `/tracks/{id}`). *Keep this as a + thin fallback, not the primary path.* + +- **(ii) Pure resolver helper, applied at click sites (RECOMMENDED).** A single + `ReleaseRoutes.DetailHref(ReleaseDto)` (or `(long id, ReleaseMedium medium)`) helper — **one + table, one location** — that every release-title / release-card click consumes. `ArchiveView` + already has a private `DetailHref` switch (lines 121–126); **promote it to the shared helper** so + the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip, + no flash. This is the *One source, multiple views* discipline applied to routing. + +- **(iii) Both.** The resolver helper (ii) is the primary path; a thin `/tracks/{id}` redirect page + (i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's + literal route. The redirect page consumes the *same* resolver helper. + +**Recommend (iii): resolver helper as the spine, plus a thin `/tracks/{id}` redirect page that +reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router" wording *and* gives +the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The +redirect page is ~15 lines and shares the resolver, so it is not a second source of truth. + +> **Open question (Daniel):** the player-bar title currently links to **`/track/{EntryKey}`** (the +> track-cardinal `TrackDetail`). The brief says the title click should resolve medium → dedicated +> page. **Confirm the player-bar title should now point at the release detail (via the resolver), +> not the track detail.** If yes, `TrackMetaLabel`'s `` changes from `/track/{EntryKey}` to +> `ReleaseRoutes.DetailHref(Track.Release)` — and `TrackDetail`'s role shrinks (see §4, the +> normalization question on whether `TrackDetail` survives at all). + +### What "resolver" means for Cut + +`DetailHref` for a Cut today returns `/tracks?album={title}`. After §3 lands, a Cut resolves to +**`/cuts/{Id}`** (id-addressed, consistent with Session/Mix). This repoint is the hinge between +requirements 1, 2, and 3 — see the dependency note in §6. + +--- + +## 3. Requirement 2 — the `/cuts/{id}` album-detail page + +This is the phase's center of gravity: the first **multi-track** release detail page. Sessions and +Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single +with an ordered track list. + +### 3.1 Layout (Daniel's spec, literal) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ← All cuts │ +│ │ +│ ┌─────────────────────────────┐ ┌──────────────────────┐ │ +│ │ RELEASE NAME (h3) │ │ │ │ +│ │ Artist (h6, primary) │ │ COVER ART │ │ +│ │ Genre · 2025 │ │ (large-ish, │ │ +│ │ │ │ theme border) │ │ +│ │ [ ▶ Play ] [ ⤴ Share ] │ │ │ │ +│ └─────────────────────────────┘ └──────────────────────┘ │ +│ ↑ header content LEFT ↑ cover RIGHT │ +│ │ +│ ───────────────────────────────────────────────────────────│ +│ 1. ▶ Track One 3:42 │ +│ 2. ▶ Track Two 4:18 │ +│ 3. ▶ Track Three 2:55 │ +│ … │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Header left:** release name, artist, genre, release year, **Play** + **Share** buttons. +- **Cover right:** large, **theme border** around the image (a `deepdrft-`-prefixed border using a + palette token — mirror the existing cover treatments but with an explicit framed border, which is + the new visual element). +- **Track list below:** ordered rows, each with a play button. Row click / row play streams that + track. +- **Header Play** starts **track 1**. + +### 3.2 Compose `ReleaseDetailScaffold`, or not? — the load-bearing design call + +Phase 9 §5.3 established `ReleaseDetailScaffold` as the shared detail scaffold and committed to +"refactor `TrackDetail` onto it; per-medium variance rides slots." The scaffold owns the **invariant +trio**: back link, masthead (title + artist), play/share affordance. `MixDetail` composes it; +`SessionDetail` deliberately diverges (overlay layout). The question for Cuts: + +- **(i) Compose the scaffold.** The Cut header *is* the invariant trio (title, artist, play, share) — + almost exactly what the scaffold provides. The cover goes in the `Hero` slot; the genre/year go in + `MetaContent`; the **track list** rides a new `BodyContent` slot (the scaffold has no body slot + today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: *"named slots are fine + where genuinely needed, e.g. `BodyContent` for the Cut/Album multi-track listing"*). +- **(ii) Bespoke page** (like `SessionDetail`). Full control over the left/right header split, but + duplicates the play/share wiring and the back-link/masthead the scaffold already owns. + +**Recommend (i): compose the scaffold, add one `BodyContent` slot.** Two caveats that decide whether +this is clean or a fight: + +1. **The header layout is left-content / right-cover; the scaffold's masthead is a top row.** The + scaffold today renders masthead-then-Hero vertically (`MixDetail` stacks cover below the + masthead). The Cut layout wants masthead and cover **side by side**. This is a **layout variance**, + and Phase 9 §5.3 is explicit: *"a boolean layout parameter on the scaffold is a design failure — + that variance belongs in a slot."* So the right answer is **not** a `HeroBesideMeta` flag on the + scaffold. Two clean options: + - **(a)** The Cut page supplies its whole left+right header as the page content and uses the + scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead + is *one* arrangement and the Cut wants a different one, so the Cut composes a richer header into + a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header. + - **(b)** Generalize the scaffold's header region into a `Header` slot with the current + masthead+play row as the default content, so `MixDetail`/`TrackDetail` are unchanged (default) + and `CutDetail` supplies a left/right `Header`. This is the cleaner *One source* move but + touches the scaffold's shared contract. + - **Recommend (b)**, but flag it: it is a scaffold-contract change that ripples to every composer. + If Wave pressure makes (b) risky, **(ii) bespoke page** is the honest fallback — record it as + deliberate divergence (as `SessionDetail` already is) rather than bending the scaffold with a + boolean. + +2. **The play/share affordance differs.** The scaffold renders a `PlayStateIcon` (icon toggle) + + `SharePopover`. Daniel's Cut spec says **Play and Share buttons** (labeled buttons, per the + mockup). If the Cut wants text buttons rather than the icon idiom, that is a **slot for the + affordance row**, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the + icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording). + +### 3.3 Data path — the track list + +The Cut page needs the release **and its ordered tracks**. Both primitives exist: + +- **Release:** `IReleaseDataService.GetById(id)` → `ReleaseDto` (with `Title`, `Artist`, `Genre`, + `ReleaseDate`, `ImagePath`, `Medium`, `ReleaseType`, `TrackCount`). +- **Tracks:** the track-data service already supports a **`releaseId`-filtered** track page — + `ReleaseDetailViewModel.Load` uses `GetPage(pageNumber: 1, pageSize: 1, releaseId: …)` to resolve + the single track for Session/Mix. The Cut page issues the same call with a **larger page size** + (cover the whole album — `pageSize: 100` matches the gallery convention) to get the ordered list. + +**Ordering.** The track list must render "in order." Verify the track page's default sort yields a +stable, meaningful order for an album (track-number if it exists; otherwise insertion/`Id` order). +*Open question:* does `TrackEntity` carry a track-number / ordinal field? From the current schema it +does **not** (`Id, EntryKey, TrackName, Artist, Album, Genre, ReleaseDate, ImagePath`). If album +track order matters beyond insertion order, that is a **data-model gap** (a `TrackNumber` column) — +flagged in §7 as a Daniel decision, *not* assumed into this phase. + +**New `CutDetailViewModel` vs. extend `ReleaseDetailViewModel`.** `ReleaseDetailViewModel` resolves +*one* track. The Cut page needs *many*. Two options: + +- Extend `ReleaseDetailViewModel` with an optional `Tracks` collection populated when the medium is + Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought. +- A dedicated `CutDetailViewModel` (loads release + full track list). Cleaner SRP; the Cut detail is + genuinely a different shape (multi-track) from the single-release VM. + +**Recommend a dedicated `CutDetailViewModel`** + a `CutDetail` page deriving the same prerender-bridge +discipline `ReleaseDetailBase` encodes (persist release + tracks across the prerender→WASM seam, +guard restore on id). If the bridge logic is substantial, consider generalizing `ReleaseDetailBase`'s +bridge into a shared base both single- and multi-track details use — but only if it doesn't force a +medium conditional into the base. Flag the choice; don't pre-commit the implementer. + +### 3.4 Play wiring + +- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current, + else `SelectTrackStreaming`). +- **Header Play:** stream **track 1** (`Tracks.FirstOrDefault()`). If a queue/playlist model existed + (`PLAN.md §1.3`), header Play would enqueue the whole album — **it does not today**, so header Play + starts track 1 and the rest is manual. *Adjacent opportunity, not in scope* — see §7. + +--- + +## 4. Requirement 3 — normalization and reduction + +The brief: eliminate the `/tracks?album` page view; `/tracks` collapses to a pure router; identify +other redundancy. + +### 4.1 The `/tracks?album` retirement is gated on §2 and §3 + +`/tracks?album={title}` has **two live consumers** today: + +- `AlbumsView` (`/cuts`) cards → `OpenAlbum` → `/tracks?album={title}` (line 62). +- `ArchiveView` Cut cards → `DetailHref` → `/tracks?album={title}` (line 125). + +Both must be **repointed to `/cuts/{id}`** before the `?album=` view can be retired. So the ordering +is: §3 (`/cuts/{id}` exists) → §2 (resolver returns `/cuts/{Id}` for Cut) → §4.1 (repoint both +consumers) → **then** the `?album=` filter branch in `TracksView` can be removed. + +> **Scope subtlety.** "Eliminate the `/tracks?album` page view" means remove the **album-filtered +> *mode*** of the track gallery, not necessarily the whole `/tracks` route. `TracksView` also +> supports `?genre=` and `?q=` and a plain unfiltered gallery. Decide (Daniel) whether: +> - **(A)** remove only the `album` query branch (keep `/tracks` reachable with genre/search), or +> - **(B)** retire `TracksView` **entirely** — the Archive (§release-cardinal search) subsumes it, +> and Phase 9 §8.I already demoted `/tracks` from the nav. Cuts now have a proper detail page; +> the track-cardinal gallery may have no remaining job. +> +> **Recommend (B) as the eventual target, (A) as the safe first step.** If `/cuts/{id}` + `/archive` +> cover every browse/play path, `TracksView`, `TrackDetail`, `TrackCard`, `TracksGallery`, and +> `GalleryViewMode` become removable surface — a meaningful reduction. But that is a bigger cut than +> the brief literally asks; confirm before deleting. See §4.3. + +### 4.2 What `TrackDetail` becomes + +If §2's resolver repoints the player-bar title to the **release** detail (the recommended +direction), `TrackDetail` (`/track/{EntryKey}`) loses its only inbound link from the player bar. +`TrackCard` still links to it (`/track/{EntryKey}`). If `TracksView`/`TrackCard` are retired (4.1 +option B), `TrackDetail` has **no inbound links** and is dead surface — retire it too. If `/tracks` +survives (option A), `TrackDetail` stays as the track-cardinal detail. + +**This is the cleanest reduction lever in the phase:** the track-cardinal stack (`TracksView`, +`TrackDetail`, `TrackCard`, `TracksGallery`) exists from the pre-medium era. Phase 9 made the release +cardinal; Phase 11's Cuts page removes the last reason a Cut needed the track gallery. Whether to +pull the whole stack is a Daniel call (§7), but the phase should **name the dead surface explicitly** +so the reduction is deliberate, not accidental. + +### 4.3 Candidate redundancy inventory (for the normalization pass) + +Surfaced from the read; each is a *candidate*, not a commitment: + +1. **`/tracks?album` filter branch** — retire (gated, §4.1). **High confidence.** +2. **`TracksView` + `TrackCard` + `TracksGallery` + `GalleryViewMode`** — track-cardinal gallery + stack. Removable iff Cuts page + Archive cover all paths (§4.1 B). **Daniel decision.** +3. **`TrackDetail` (`/track/{EntryKey}`)** — removable iff its inbound links go (§4.2). **Follows 2.** +4. **`GenresView` (`/genres`)** — already demoted from nav (§8.I), route kept. The Archive has a + genre filter. Is `/genres` still needed? Probably retire-able, but **out of this phase's stated + scope** — flag as adjacent. **Daniel decision, low urgency.** +5. **`AlbumsView` `OpenAlbum`** — repointed from `/tracks?album` to `/cuts/{id}` (§4.1). **Part of + the work, not removable.** +6. **`ArchiveView.DetailHref` + `ArchiveView.MediumLabel`** — `DetailHref` promotes to the shared + `ReleaseRoutes` resolver (§2). `MediumLabel` is a label lookup; check whether it duplicates the + CMS `MediumTypeLabels` (§8.D) or the Archive's own medium-chip labels — if the public side has two + medium-label lookups, **consolidate to one**. **Medium confidence.** + +> The phase should land **(1)** for sure, **decide (2)+(3) as a pair** with Daniel, and treat +> **(4)+(6)** as opportunistic tidy-ups. Do not let the reduction sprawl — normalize what the four +> commitments *touch*, surface the rest as adjacent. + +--- + +## 5. Requirement 4 — Archive filters in the URL + +**This is a URL-binding pass over the existing `ArchiveView`, borrowing the `TracksView` pattern +verbatim.** No new browser, no new data path — the filter state already drives `LoadReleases`; the +change is making that state **enter and leave via the query string**. + +### 5.1 Target URL scheme + +``` +/archive?q={search}&medium={cut|session|mix}&genre={genre} +``` + +- All three params optional; omitting one means "no filter on that axis" (matches the current + null-means-all semantics). +- `medium` uses the same lowercase enum token the data service already speaks + (`Medium.ToString().ToLowerInvariant()`), parsed back with `Enum.TryParse(ignoreCase:true)` + + `Enum.IsDefined` — the exact posture `BatchUpload` (§8.E) and the API already use. +- Plain `/archive` (no params) = the unfiltered first page, which is the bridged/prerendered state. + +### 5.2 The binding mechanics (borrow `TracksView`) + +`TracksView.razor.cs` is the template: + +- **In (URL → state):** add `[SupplyParameterFromQuery]` for `q`, `medium`, `genre`. Seed + `SearchText` / `_selectedMedium` / `_selectedGenre` from them in `OnInitializedAsync` **before** + the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge + restore is skipped when a filter is active — `ArchiveView` already has `HasActiveFilter` gating + exactly this). +- **Out (state → URL):** each of `OnSearchInput` / `OnMediumSelected` / `OnGenreSelected` calls + `Navigation.NavigateTo($"/archive?{composed query}")` **instead of** (or before) calling + `LoadReleases` directly. The query-param change drives the re-fetch. + +### 5.3 The one real subtlety — same-route query change does not re-run `OnInitialized` + +Blazor reuses the component on a same-route query-string change and fires `OnParametersSet`, **not** +`OnInitializedAsync` (the `TracksView.ClearFilter` comment, lines 117–120, documents exactly this +trap). So the filter→fetch reaction must live where it sees the change: + +- **Option A (history-driven):** filter handlers only `NavigateTo` the new URL; move the + state-seeding + `LoadReleases` into `OnParametersSet`/`OnParametersSetAsync` keyed off the query + params. Cleanest — the URL is the single source of truth; back/forward "just works" because each + nav re-runs the same seed-and-fetch. **Recommended.** +- **Option B (dual-write):** handlers both `NavigateTo` *and* `LoadReleases` directly. Simpler diff + but the URL and the fetch are two writes that can drift, and back/forward needs separate handling. + +**Recommend Option A.** It makes the URL the source of truth (which is the whole point of the +requirement) and gets shareable links + back/forward correctness as a structural consequence rather +than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces +(400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the +`OnParametersSet` reaction should be idempotent on identical param sets — mirror the +`_loadedEntryKey` guard idiom). + +### 5.4 Persistence interaction + +The bridged unfiltered first page (`PersistKey = "archive-releases"`) must keep restoring **only** +when no filter is active — `ArchiveView` already gates persist + restore on `HasActiveFilter`. The +URL-binding pass must preserve that gate: a `/archive?medium=mix` direct load must **fetch**, not +restore the unfiltered bridge. The existing `HasActiveFilter` check already expresses this; the +seed-from-URL step just has to run before the restore decision (as §5.2 specifies). + +--- + +## 6. Wave decomposition + +Sequenced so the structural dependency (Cuts page → resolver → repoint → retire) is honored, and the +two independent tracks (Archive URL; Cuts page) can run in parallel. + +``` +Wave A (parallel start) Wave B (parallel start) +┌──────────────────────────┐ ┌────────────────────────────┐ +│ 11.A /cuts/{id} page │ │ 11.D Archive filters →URL │ +│ + CutDetailViewModel │ │ (TracksView-pattern bind) │ +│ + cover theme border │ └────────────────────────────┘ +│ + ordered track list │ (fully independent — +└──────────┬───────────────┘ touches only ArchiveView) + │ + ▼ +┌──────────────────────────┐ +│ 11.B medium→route │ +│ resolver (ReleaseRoutes) │ +│ + thin /tracks/{id} │ +│ redirect page │ +│ + repoint player-bar │ +│ title + Archive + │ +│ AlbumsView cards │ +└──────────┬───────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ 11.C Normalization: │ +│ retire /tracks?album │ +│ branch; decide + (maybe) │ +│ retire track-cardinal │ +│ stack; consolidate │ +│ medium-label lookups │ +└──────────────────────────┘ +``` + +- **11.A — `/cuts/{id}` detail page.** The new page, its VM, the cover theme border, the ordered + track list, header Play→track 1, row Play. Independent of everything except the existing + `GetById` + `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's + Cut resolution.** +- **11.B — medium→route resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared + `ReleaseRoutes` helper; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title, + Archive cards, and `AlbumsView` cards through it; add the thin `/tracks/{id}` redirect page. + **Depends on 11.A.** +- **11.C — normalization.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both + consumers); execute the agreed reduction from §4.3 (decide 4.3#2/#3 with Daniel first). **Depends + on 11.B.** +- **11.D — Archive filters in the URL.** Fully independent — touches only `ArchiveView`. Can land + first, last, or alongside Wave A. **No dependency.** + +**Critical path:** 11.A → 11.B → 11.C. **11.D is free-floating.** + +--- + +## 7. Open questions and adjacent gaps (need Daniel) + +**Decisions inside the four commitments:** + +1. **Player-bar title target (§2).** Confirm the title click should resolve **release** detail (via + the resolver), replacing the current `/track/{EntryKey}` link. *Recommend yes* — it is the premise + of requirement 1. +2. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized + `Header` slot (recommended) vs. bespoke page like `SessionDetail`. Sets whether the scaffold + contract changes. *Recommend the `Header`-slot generalization; bespoke as fallback.* +3. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent + with Session/Mix) vs. labeled **Play/Share buttons** (the literal spec wording). *Minor — flag.* +4. **Track ordering / `TrackNumber` (§3.3).** Does an album need explicit track ordering beyond + insertion order? If yes, that is a **`TrackEntity.TrackNumber` data-model addition** (new column + + migration + CMS field + ordering in the read) — a real schema gap. *Recommend: ship 11.A on + insertion/`Id` order; raise `TrackNumber` as its own small phase if hands-on use shows albums + landing out of order.* **Daniel call — do not assume into Phase 11.** +5. **`/tracks` retirement scope (§4.1).** Remove only the `album` branch (A, safe) vs. retire the + whole track-cardinal stack — `TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery` (B, bigger + reduction). *Recommend B as the target, A as the floor.* This is the phase's biggest reduction + decision. +6. **`/genres` fate (§4.3#4).** Already nav-demoted; Archive has genre filtering. Retire `/genres` + too? *Out of stated scope — flag as adjacent, low urgency.* + +**Adjacent gaps surfaced by the read (not in the four commitments — for the roadmap, not this +phase):** + +- **No album-play queue.** Header Play starts track 1 only; there is no "play the whole album" + because the player is single-slot (no queue — `PLAN.md §1.3` preload/queue is still deferred). A + Cuts album page is the **strongest product argument yet for a queue model** — "play album" is the + expected affordance on an album page. *Recommend surfacing this as a renewed case for §1.3, not + building it in Phase 11.* The Cut page's Play button is the natural future entry point; design the + header Play so a future "enqueue album" is a swap of the handler, not a rewrite (the + *Design for adaptability up front* seam). +- **Share on a Cut shares a track, not the release.** `SharePopover` takes an `EntryKey` (a track). + A Cut's header Share should arguably share the **release URL** (`/cuts/{id}`), not a single track's + embed. *Flag:* the existing `SharePopover` is track-keyed; sharing a release is a new share target. + Decide whether the Cut header Share shares the release page or punts to track-share. *Recommend + release-URL share for the Cut header* — but it is a `SharePopover` extension, so flag it. +- **`TrackDto.Release` nullability on the player bar.** `TrackMetaLabel` guards `Track.Release?` — + if a track ever loads without its release populated, the resolver (§2) has no medium to route on. + Verify the player's `TrackDto` always carries `Release` (the streaming select path). *Low risk; + worth a verification line in 11.B.* +- **No `ReleaseDate`-only-year display helper.** The Cut header shows "release year"; `MixDetail`/ + `SessionDetail` show "MMMM yyyy". Minor inconsistency — the Cut header wants just the year per the + spec. *Trivial; note it so the implementer doesn't copy the month-year format.* + +--- + +## 8. Why this is consistent with the system's grain + +- **Release-cardinal everywhere.** Phase 9 + Wave 8 moved the public site to release-cardinal browse + (`/archive`) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and + makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is + vestigial — which is *why* the reduction (§4) is available. +- **One source, multiple views.** The medium→route resolver (§2) is one table consumed by the player + bar, Archive, and Cuts cards — not three `DetailHref` switches. The Cut detail reuses the + scaffold's invariant trio and the existing `GetById` + `releaseId`-filtered track page — no new + data path. (Memory: *One source, multiple views*.) +- **URL as source of truth (§5).** Borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern + makes the Archive's filters addressable, which is the same shareable-link discipline the embed + player and `/tracks?album` deep links already established. +- **Extension, not modification.** The resolver and the `ReleaseDetailScaffold` `Header`/`BodyContent` + slots are additive; a future medium's detail page composes the same scaffold and the resolver gains + one entry — the Phase 9 Open/Closed discipline, unchanged. + +--- + +## 9. Verified facts (read against live source 2026-06-15) + +- **No `/tracks/{id}` route exists.** Track-cardinal detail is `/track/{EntryKey}` (`TrackDetail.razor` + line 1). Player-bar title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). +- **`/sessions/{id}` and `/mixes/{id}` exist and are mature** (`SessionDetail.razor`, `MixDetail.razor`, + both id-addressed, both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes + `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). +- **Cuts have no single-release detail page.** `/cuts` is `AlbumsView` (medium-parameterized card + grid); cards open `/tracks?album={title}` (`AlbumsView.razor.cs` line 62). `/albums` → `/cuts` + redirect exists (`AlbumsRedirect.razor`). +- **`/archive` is release-cardinal, filters held in component fields not the URL.** `ArchiveView` + has `_selectedMedium`, `_selectedGenre`, `SearchText` as private state; no `[SupplyParameterFromQuery]`, + no `NavigateTo` on filter change (`ArchiveView.razor.cs`). It has a private `DetailHref` switch + (lines 121–126) routing Session→`/sessions/{id}`, Mix→`/mixes/{id}`, Cut→`/tracks?album={title}`. +- **`TracksView` already reads `?album=`/`?genre=`/`?q=` from the URL** via `[SupplyParameterFromQuery]` + (`TracksView.razor.cs` lines 21–23) — the pattern requirement 4 borrows. It documents the + same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117–120). +- **`ReleaseDetailScaffold`** owns the invariant trio (back link, masthead, play/share) + `Hero` and + `MetaContent` slots; **no body/track-list slot today** (`ReleaseDetailScaffold.razor`). +- **Data primitives for the Cut page both exist:** `IReleaseDataService.GetById(id)` returns a full + `ReleaseDto`; the track-data service supports `releaseId`-filtered paging (`ReleaseDetailViewModel.Load` + uses `GetPage(…, releaseId: …)`). `ReleaseDto` carries `TrackCount` but the **track list needs the + filtered track page** (the DTO has no nested track collection). +- **`TrackEntity` has no track-number / ordinal field** (`Id, EntryKey, TrackName, Artist, Album, + Genre, ReleaseDate, ImagePath`). Album track ordering beyond insertion order is a data-model gap. +- **`Pages.cs` `MenuPages`** = ARCHIVE (→ `/archive`) with Cuts/Sessions/Mixes children; `/tracks` + and `/genres` are absent from nav, routes reachable (§8.I). +- **`SharePopover` is track-keyed** (takes `EntryKey`) — sharing a release is a new target.