diff --git a/PLAN.md b/PLAN.md index 775cbe1..8ef0afc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,17 +204,19 @@ 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 the public site, make every navigation an addressable shareable URL, and make the album a first-class playable object** (ordered, queue-able, shareable). **Seven Daniel commitments** (the original four plus three added 2026-06-15 when he resolved the open questions and expanded scope): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3). Full design, framing corrections, wave decomposition, gap analysis: `product-notes/phase-11-public-site-enhancements.md`. +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 the public site, make every navigation an addressable shareable URL, and make the album a first-class playable object** (ordered, queue-able, shareable). **Eight Daniel commitments** (the original four plus four added 2026-06-15 when he resolved the open questions and expanded scope): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3); (8) a release **Description** field — multiline free-text on the base release (all media), edited from the CMS add/edit forms and rendered as a text block on every detail page. Full design, framing corrections, wave decomposition, 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). `ReleaseGallery` is the shared release-card grid — but **only Sessions/Mixes use it**; Archive and Cuts re-implement equivalent card markup inline. The real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}`), and **`/archive` holds its filters in component fields, not the URL**. The queue/playlist **does not exist** (single-slot player). **Headline correction — commitment 5 is already built.** The brief framed the track ordinal as a new column + EF migration + a Daniel-gated apply step. **The read shows it already shipped in Phase 8:** `TrackEntity.TrackNumber` (1-based, non-null), migration `20260611005700` **already applied**, `TrackDto` mirror, API write path (validated `> 0`), CMS reorder (`BatchEdit` assigns ordinal from list position on submit), and the read already `.OrderBy(t => t.TrackNumber)`. **No new schema, no migration to gate.** Commitment 5 collapses to *verify-and-consume*: confirm the public read projects/sorts `TrackNumber` and that `CutDetailViewModel` orders by it (a one-line fix if not). See spec §3a. +**Mirror-image on commitment 8 — the Description column is genuinely new.** Where commitment 5 turned out already-built, commitment 8 is the opposite: **no `Description` member exists** on `ReleaseEntity` or `ReleaseDto` (greps return nothing; the entity carries `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` + the two metadata satellites). So commitment 8 **is** the real cross-stack schema project — just for a different field: a new base-`ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing (`UpdateTrackMetadataRequest` + upload form + the unified services — threaded **wherever `Genre` is**, since there is no dedicated release-update endpoint; release-cardinal fields ride the track update/upload path), the CMS `AlbumHeaderFields` multiline input, and the detail-page text block. It is a base field (uniform across media) so it lives on the base release, **not** a per-medium satellite (Phase 9 spine). See spec §3d. + **Framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** at click sites (the player bar already carries release id + medium — no round-trip), plus a thin `/tracks/{id}` redirect page for 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 — not a new browser. **Design discipline.** The medium→route resolver is **one table** (`ReleaseRoutes.DetailHref`) consumed by the player bar, Archive, and Cuts cards. The shared `ReleaseGallery` becomes the **one** release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card `HrefResolver`), not three inline copies (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + a `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3). The queue is a separate `IQueueService` orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → `QueueService.PlayRelease` with no page change (memory *Design for adaptability up front*). -Sequenced as **six waves**; the critical path is `11.A → 11.B → 11.C`, with 11.D / 11.E / 11.F hanging off it. +Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, with 11.D / 11.E / 11.F / 11.G hanging off it. - **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by `TrackNumber`) with per-row play, header Play. New `CutDetailViewModel`; reuses `GetById` + the `releaseId`-filtered track page (both exist). Ordinal is a **verification** (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.** - **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title (→ release), Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.** @@ -222,10 +224,11 @@ Sequenced as **six waves**; the critical path is `11.A → 11.B → 11.C`, with - **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Touches only `ArchiveView`. **Free-floating — but coordinate with 11.C** (both edit `ArchiveView`). - **11.E — release-level Share.** `SharePopover` gains a release mode that copies `ReleaseRoutes.DetailHref(release)`; wire the Cut header Share to it. **Depends on 11.B** (resolver) + a release detail to share. - **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature. +- **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing (`UpdateTrackMetadataRequest` + upload form + the unified services, threaded wherever `Genre` is), CMS `AlbumHeaderFields` multiline input (§3d). **Free-floating, can start cold day one** — the only gate is Daniel's migration go-ahead. The **detail-page render is NOT in this wave**: the Cut text block rides 11.A, the Session/Mix block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order. -**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D and 11.F parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A). The two cold-start items are **11.A** and **11.F** — kick both off first so "play album" works on first ship of the Cut page. +**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D, 11.F, 11.G parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). The cold-start items are **11.A**, **11.F**, and **11.G** — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track. -**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); `/genres` fate (out of scope, flag as adjacent). +**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release **Description** field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 2000–4000); `/genres` fate (out of scope, flag as adjacent). --- diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md index 25fd393..b1af76a 100644 --- a/product-notes/phase-11-public-site-enhancements.md +++ b/product-notes/phase-11-public-site-enhancements.md @@ -21,9 +21,10 @@ demoted from the nav (route kept). That work landed and is stable on dev (2026-0 Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the -scope to **seven**. They share one spine: **make the release the cardinal unit of the public site, +scope to **eight**. They share one spine: **make the release the cardinal unit of the public site, make every navigation an addressable, shareable URL, and make the album a first-class playable -object** (ordered, queue-able, shareable). The seven: +object** (ordered, queue-able, shareable) — and now, **a release that can describe itself** in prose. +The eight: 1. **Cuts detail page** (`/cuts/{id}`) — structural; the phase's center of gravity. 2. **Player-bar release-title → release detail** via a medium→route resolver — structural. @@ -35,12 +36,20 @@ object** (ordered, queue-able, shareable). The seven: 6. **Release-level Share** — **new scope**; a Cut/Session/Mix header Share shares the release URL. 7. **Play-queue system** — **new scope**; absorbs the deferred `PLAN.md §1.3`. The Cuts "play album" affordance is its first consumer. +8. **Release Description field** — **new scope (2026-06-15)**; a multiline free-text field on the + base release (all media), edited from the CMS add/edit forms and rendered as a text block on every + release detail page. Unlike commitment 5, this **is** a genuinely new column + migration. See §3d. This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse pages already share a `ReleaseGallery` card component; the detail pages already share `ReleaseDetailScaffold`; the Archive already has all filter state). The structural new work is the **Cuts detail page** and the **queue model** (the one real architecture decision). Everything else -is closing asymmetries and consolidating rendering that drifted into per-surface copies. +is closing asymmetries and consolidating rendering that drifted into per-surface copies. The one +piece of genuinely new persisted state the phase introduces is the **release Description column** +(commitment 8) — a small, self-contained vertical slice (column → migration → DTO → write path → +CMS form input → detail-page text block) that touches no existing wave's surface and follows the +**exact channel Genre already uses** (release-cardinal field carried on the track update/upload +request, projected onto the linked release row — there is no dedicated release-update endpoint). > **Headline correction on commitment 5 (read against live source, 2026-06-15).** Daniel asked for > "an explicit ordinal column, editable from the CMS, with a Daniel-gated migration." **That column @@ -57,6 +66,20 @@ is closing asymmetries and consolidating rendering that drifted into per-surface > use reveals a gap (e.g. the *public* track read does not project `TrackNumber`), that is a small > wiring fix, not the schema project the brief anticipated. +> **The mirror-image note on commitment 8 (release Description; read against live source, 2026-06-15).** +> Where commitment 5 turned out *already built*, commitment 8 is the opposite: **no `Description` +> column exists** on `ReleaseEntity` or `ReleaseDto` (greps return nothing; the entity carries +> `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` and the two metadata +> satellites, no description). So commitment 8 **is** the real cross-stack schema project the brief's +> commitment-5 framing anticipated — just for a different field. The honest scope: a new +> `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), the `ReleaseDto` mirror, +> the `TrackConverter` round-trip (both directions), the write-path plumbing (the +> release-cardinal-fields thread through `UpdateTrackMetadataRequest` / the upload form / the +> `UnifiedTrackService` and `UnifiedReleaseService` write — the **same path Genre travels**), the CMS +> `AlbumHeaderFields` multiline input, and the detail-page text block. §3d carries the full spec. This +> is a clean vertical slice that gates the detail-page render but shares no surface with 11.A–F — which +> is why it lands as its own wave (11.G), not a graft onto an existing one. + ### What already exists (verified against live source, 2026-06-15) | Surface | State | File | @@ -74,6 +97,7 @@ is closing asymmetries and consolidating rendering that drifted into per-surface | `SharePopover` | **Exists, track-keyed.** Takes an `EntryKey`; "Copy link" + "Embed player". No release-level share target. | `Controls/SharePopover.razor` | | Play queue / playlist | **Does not exist.** Player is single-slot (`StreamingAudioPlayerService` holds one `CurrentTrack`). No notion of "next." `PLAN.md §1.3` (preload + queue) is deferred — **now absorbed here**. | — | | `TrackEntity` ordinal | **ALREADY EXISTS — landed in Phase 8.** `TrackEntity.TrackNumber` (int, 1-based, default 1, non-null), column `track_number`, migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**. `TrackDto.TrackNumber` mirrors it; `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber` + `TrackController` validate (`> 0`) and persist it; `BatchEdit` already sets it from reorderable list position on submit; `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. **Commitment 5 is not new schema — it is verify-and-consume.** | `TrackEntity.cs:17`, `TrackConfiguration.cs:37`, `BatchEdit.razor:192/225` | +| `ReleaseEntity` Description | **DOES NOT EXIST.** No `Description` member on `ReleaseEntity` or `ReleaseDto` (greps return nothing). The entity carries `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` + `SessionMetadata`/`MixMetadata` — no description. **Commitment 8 is the real new-column project** (column + migration + DTO + converter + write path + CMS input + detail block). | `ReleaseEntity.cs:13-30`, `ReleaseDto.cs:10-33` | ### Three framing corrections (the brief's vocabulary vs. the live routes) @@ -103,9 +127,10 @@ these up front so the implementer is not misled: --- -## 1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in) +## 1. The eight commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in) -The original four (1–4) plus three Daniel added when he resolved the open questions (5–7). +The original four (1–4) plus four Daniel added on 2026-06-15: three when he resolved the open +questions (5–7), and the release Description field (8) as a focused addition the same day. 1. **Player-bar release-title → release detail, via a medium→route resolver.** **DECIDED (2026-06-15):** the release-title click resolves the release's `ReleaseMedium` → the correct @@ -152,6 +177,17 @@ The original four (1–4) plus three Daniel added when he resolved the open ques separate orchestrating service) — framed with a recommendation in §3c; the final call is staff-engineer's at implementation. (Was an adjacent gap; promoted to scope. See §3c.) +8. **Release Description field.** **NEW SCOPE (2026-06-15):** every release medium (Cut, Session, Mix) + gains a **Description** — a multiline / paragraph free-text field describing the release. It is a + **base `ReleaseEntity` field** (applies to all media uniformly; per the Phase 9 spine it belongs on + the base release, **not** a per-medium metadata satellite). Two surfaces: (a) the **CMS add/edit + forms** gain a multiline text input (in `AlbumHeaderFields`, alongside Genre/Release Date — base + fields, all media); (b) the **release detail pages** (`/cuts/{id}`, `/sessions/{id}`, `/mixes/{id}`) + gain a **text block** rendering it. Confirmed against live source: **the column does not exist**, so + this carries a real EF migration (Daniel-gated apply), DTO mirror, converter round-trip, and + write-path plumbing — the same channel Genre travels. See §3d. (Focused addition; lands as its own + schema slice, wave 11.G, with the render folded into 11.A + a small touch to Session/Mix detail.) + --- ## 2. Requirement 1 reframed — the medium→detail resolver @@ -521,6 +557,123 @@ Phase 11; the preload half remains deferred** there (and gates 1.4/1.5 as before --- +## 3d. Commitment 8 — the release Description field (a real new column) + +**Recommendation: add `Description` to the base `ReleaseEntity`, thread it through the existing +release-cardinal write channel exactly as `Genre` is threaded, and render it as a text block via the +detail-page slots already in scope.** This is a clean vertical slice — no new architecture, no new +endpoint — but unlike commitment 5, it **is** a genuine new column with a real migration. The Phase 9 +spine decides its placement without debate: it applies to **all media uniformly**, so it lives on the +**base release**, not a per-medium satellite. + +### 3d.1 Why the base release, not a satellite (the placement is not a judgment call) + +Phase 9 (`ReleaseConfiguration` comment, lines 45–56) is explicit: the default home for medium-varying +data is a satellite metadata table; `ReleaseType` is the *one* allowed exception, justified solely by +`/cuts` read volume, and **"Future media MUST NOT copy this pattern."** Description is the easy case — +it does **not** vary by medium (every Cut, Session, and Mix has the same kind of prose blurb), so the +satellite question never arises. A field that is uniform across media belongs on the base table by the +same logic that puts `Genre` and `ReleaseDate` there. **No new satellite, no medium conditional, no +converter null-for-non-matching-medium dance** (contrast `ReleaseType`, which the converter nulls for +Session/Mix — Description needs none of that; it is carried verbatim for all media). + +### 3d.2 What does NOT exist today (verified against live source, 2026-06-15) + +| Layer | State | Evidence | +|---|---|---| +| Entity | **No `Description`.** `ReleaseEntity` has `Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`, `Tracks`, `SessionMetadata?`, `MixMetadata?`. | `ReleaseEntity.cs:13-30` | +| DTO | **No `Description`.** `ReleaseDto` mirrors the above + read-model `TrackCount`. | `ReleaseDto.cs:10-33` | +| EF config | No `description` column mapped. | `ReleaseConfiguration.cs:24-72` | +| Converter | `TrackConverter.Convert(ReleaseEntity)` / `Convert(ReleaseDto)` map every base field both directions; no `Description` line. | `TrackConverter.cs:19-65` | + +So the column is genuinely new. **A grep for `[Dd]escription` across `ReleaseEntity.cs` returns +nothing** — there is no field to verify-and-consume (the commitment-5 outcome); this is the build. + +### 3d.3 The write path — Description rides the Genre channel exactly + +This is the load-bearing realization that keeps the slice small: **there is no dedicated +release-update endpoint.** Release-cardinal fields are carried on the *track* update/upload request and +projected onto the linked release row by the unified services. The CMS edits the whole release through +`BatchEdit`, whose `AlbumHeaderFields` owns `AlbumName`/`Artist`/`Genre`/`ReleaseDate`, and on submit +each track's `CmsTrackService.UpdateAsync(...)` / `UploadTrackAsync(...)` carries those +release-cardinal fields; `TrackController` then routes them to the linked release (see the +`PUT api/track/meta/{id}` contract: *"release-cardinal fields … update the linked release"*). + +So Description travels the **identical thread** as Genre: + +1. **`ReleaseEntity.Description` (string?, nullable)** — base table; EF `description` column, `HasMaxLength` + generous for paragraph prose (e.g. 2000–4000; pick a ceiling, mirror the `Genre`/`Title` `HasMaxLength` + idiom in `ReleaseConfiguration`). **Nullable** — existing rows migrate with `NULL`, no data migration. +2. **EF migration** — `dotnet ef migrations add AddReleaseDescription`. **Daniel-gated apply** (do not + auto-run `database update`; the migration is generated and committed, applied on Daniel's go). +3. **`ReleaseDto.Description` (string?)** — DTO mirror. +4. **`TrackConverter`** — add `Description = entity.Description` to `Convert(ReleaseEntity)` and + `Description = dto.Description` to `Convert(ReleaseDto)`. No null-for-medium dance (it is uniform). +5. **Write request plumbing** — add `Description` to the release-cardinal field set carried on the + track update + upload path: `UpdateTrackMetadataRequest` gains `string? Description`; the upload form + gains a `description` field; `TrackController` and `UnifiedTrackService` / `UnifiedReleaseService` + thread it onto the linked release **wherever `Genre` is already threaded** (the cleanest diff is + "find every `Genre` in the write path, add a sibling `Description`"). *Note the tri-state question:* + Genre is passed as a plain nullable today (whitespace → null). Description should follow the **same + posent** — empty input → `null`, no special tri-state (it is not the cover-art `ImagePath` case). +6. **CMS input** — `AlbumHeaderFields` gains a `MudTextField` with `Lines="4"` (multiline) labeled + "Description", bound via a `Description`/`DescriptionChanged` parameter pair, wired through + `BatchEdit` (`_description` field, seeded from `release?.Description` on load — mirror `_genre` at + `BatchEdit.razor:213`) and the `BatchUpload` create form. It sits in the base-fields block alongside + Genre/Release Date, **not** inside `MediumFields` (it is base, not medium-specific). + +> **One honest call to surface (not a blocker):** the write path projects release-cardinal fields from +> *each track row* onto the shared release. For a multi-track Cut, every row carries the same +> Description, and the last write wins — which is already exactly how Genre/ReleaseDate behave, so +> Description inherits the existing semantics with no new edge case. No change wanted; just naming that +> the "release field carried per-track" model already in place covers Description for free. + +### 3d.4 The read path — the detail-page text block + +The detail pages already load a `ReleaseDto` (the Cut page via the new `CutDetailViewModel` §3.3; +Session/Mix via `ReleaseDetailBase`). Once `ReleaseDto.Description` is populated (3d.3 step 4), the +render is a **conditional text block** — show a paragraph when `Description` is non-empty, render +nothing when null (most existing rows will be null until re-edited). Placement per surface: + +- **`/cuts/{id}` (11.A):** the Cut page is new, so the text block is part of its first build — a + paragraph block in the header column or just below it (Daniel's layout §3.1 has room below the + header / above the track list; recommend **below the header masthead, above the track-list divider** + so the prose introduces the album before the tracks). Folds into 11.A's `BodyContent`/header + composition with no extra wave. +- **`/sessions/{id}` and `/mixes/{id}` (existing pages):** a small additive touch — a description + text block in each page's `MetaContent` (or equivalent body region). `SessionDetail` is the + overlay-diverged page and `MixDetail` composes the scaffold, so the block lands slightly differently + in each, but both are a few lines of conditional markup, not a structural change. + +**Styling:** a quiet paragraph — `Typo.body1`/`body2`, muted, respecting `white-space: pre-line` so +authored line breaks survive (the field is "multiline / paragraph"). Surface for Daniel only if he +wants markdown rendering vs. plain prose; **recommend plain text with preserved line breaks** for v1 +(no markdown dependency, matches the "free-text field" framing). + +### 3d.5 Wave placement — its own schema slice (11.G), render folded into 11.A + +**Honest call on whether this rides existing waves or warrants its own.** It splits cleanly: + +- The **schema + write path + CMS input** is a self-contained vertical that **shares no surface with + 11.A–F** (it touches `ReleaseEntity`/`ReleaseDto`/`TrackConverter`/the write request/`AlbumHeaderFields` + — none of which the six existing waves modify) and carries the one Daniel-gated migration in the + phase. Grafting it onto 11.A (the Cut page) would muddy 11.A's dependency story (11.A depends only on + existing data primitives; bolting a migration onto it makes the Cut page wait on a schema apply it + doesn't otherwise need). So the schema slice is **its own wave, 11.G** — independent, can start cold, + and the *only* thing that gates it is Daniel's migration go-ahead. +- The **detail-page render** is a thin consumer that **rides 11.A** (the Cut block is part of the Cut + page's first build) **plus a small additive touch to the existing Session/Mix detail pages**. It + depends on 11.G having populated `ReleaseDto.Description`, but the render degrades cleanly (a null + Description simply renders nothing), so 11.A can ship its Cut page before 11.G lands and gain the + description block the moment 11.G does — the same **design-the-seam** discipline used for the queue. + +This is the truthful decomposition: a standalone schema wave is warranted (clean vertical + the one +migration), but inventing a *second* wave for the render would be over-decomposition — the render is a +few lines folded into work already scoped. **11.G = the schema/write/CMS slice; the render rides 11.A +and a Session/Mix touch.** + +--- + ## 4. Requirement 3 — full stack retirement + shared release-card normalization **DECIDED (2026-06-15):** retire the **whole** track-cardinal stack (not just the `?album` branch), @@ -695,9 +848,10 @@ seed-from-URL step just has to run before the restore decision (as §5.2 specifi ## 6. Wave decomposition Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored, -and the genuinely independent tracks (Archive URL; the queue model) can run in parallel. Seven -commitments, six waves. The queue (11.F) is the one work item that can start cold on day one and is -the gate for the Cuts "play album" affordance. +and the genuinely independent tracks (Archive URL; the queue model; the Description schema slice) can +run in parallel. Eight commitments, **seven waves**. Two work items can start cold on day one: the +queue (11.F, gate for the Cuts "play album" affordance) and the Description schema slice (11.G, gated +only by Daniel's migration go-ahead). ``` ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ @@ -723,13 +877,16 @@ the gate for the Cuts "play album" affordance. │◄────────────────────┤ + a release detail to │ ▼ │ share (11.A/Session/Mix) │ ┌──────────────────────────┐ └──────────────────────────┘ - │ 11.C Retire + normalize │ - │ • delete track-cardinal │ - │ stack (Tracks*/Track*) │ - │ • fold Archive+Cuts cards │ - │ into shared ReleaseGallery│ - │ • consolidate medium-label│ - └──────────────────────────┘ + │ 11.C Retire + normalize │ ┌──────────────────────────┐ + │ • delete track-cardinal │ │ 11.G Release Description │ + │ stack (Tracks*/Track*) │ │ schema slice (col + EF │ + │ • fold Archive+Cuts cards │ │ migration [Daniel-gated], │ + │ into shared ReleaseGallery│ │ DTO/converter/write path, │ + │ • consolidate medium-label│ │ CMS multiline input) │ + └──────────────────────────┘ │ INDEPENDENT (cold start) │ + │ render rides 11.A + a │ + │ Session/Mix detail touch │ + └──────────────────────────┘ ``` - **11.A — `/cuts/{id}` album-detail page.** The page, `CutDetailViewModel`, cover theme border, @@ -764,6 +921,14 @@ the gate for the Cuts "play album" affordance. 11.A's header Play calls `QueueService.PlayRelease(tracks)` once 11.F lands (degrading to single-track before then). **Preload (§1.3b) is OUT of this wave** — design the seam, defer the feature (§3c.5). +- **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration + (**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing + (`UpdateTrackMetadataRequest` + upload form + the unified services — threaded wherever `Genre` is), + and the CMS `AlbumHeaderFields` multiline input (§3d). **Independent — can start cold on day one**, + in parallel with everything; the only gate is Daniel's migration go-ahead. The **detail-page render + is NOT in this wave** — the Cut text block rides 11.A; the Session/Mix text block is a small additive + touch to those existing pages. Both degrade cleanly (a null Description renders nothing), so the + render can land before or after 11.G applies (§3d.4–3d.5). **Dependency shape:** @@ -772,12 +937,16 @@ the gate for the Cuts "play album" affordance. └────► 11.E (also needs a release detail to share) 11.D (free-floating; coordinate with 11.C on ArchiveView) 11.F (free-floating cold start; 11.A's "play album" consumes it) +11.G (free-floating cold start; gated only by Daniel's migration go-ahead. + Detail-page render rides 11.A + a Session/Mix touch — degrades on null, + so render & schema can land in either order) ``` -**Critical path:** `11.A → 11.B → 11.C`. **11.D, 11.E, 11.F hang off it** (11.E after 11.B; 11.D and -11.F fully parallel). The two "can start immediately" items are **11.A** and **11.F** — kicking both -off first shortens the wall-clock to a usable Cut page (page + queue arrive together, so "play album" -works on first ship of 11.A rather than as a later retrofit). +**Critical path:** `11.A → 11.B → 11.C`. **11.D, 11.E, 11.F, 11.G hang off it** (11.E after 11.B; 11.D, +11.F, 11.G fully parallel). The "can start immediately" items are **11.A**, **11.F**, and **11.G** — +kicking 11.A + 11.F off first shortens the wall-clock to a usable Cut page (page + queue arrive +together, so "play album" works on first ship of 11.A rather than as a later retrofit); 11.G runs +alongside on its own track, surfacing the Description on the detail pages as it lands. > **Honest dependency notes (the brief asked to keep these straight):** > - **Ordinal does *not* gate a wave.** §3a showed `TrackNumber` already exists end-to-end; the @@ -788,6 +957,12 @@ works on first ship of 11.A rather than as a later retrofit). > - **Shared cards parallel the stack retirement.** Folding Archive/Cuts cards into `ReleaseGallery` > (§4.2) and deleting the track-cardinal stack (§4.1) are both in 11.C and both depend on 11.B's > repoint — they are siblings, not sequential. +> - **Description schema (11.G) gates the Description *render*, not the Cut page.** 11.A ships its Cut +> page regardless; the Description block (on 11.A and on Session/Mix) renders nothing until 11.G's +> column carries data, then lights up with no rework. So 11.G is a cold-start free-floater whose only +> hard gate is Daniel's migration go-ahead — *not* a dependency of any other wave. Unlike commitment +> 5 (already-built, a verification), commitment 8 is a real migration; unlike the queue, it has no +> architecture question — it is a mechanical vertical that rides the existing `Genre` write channel. --- @@ -809,6 +984,11 @@ works on first ship of 11.A rather than as a later retrofit). 5. **Play-queue system (§3c).** **DECIDED:** in scope; absorbs the queue half of `PLAN.md §1.3`. The Cuts "play album" affordance is its first consumer. (Was an adjacent gap; promoted.) Preload half stays deferred. +6. **Release Description field (§3d).** **DECIDED:** in scope. A multiline base-`ReleaseEntity` field + (all media), edited from the CMS add/edit forms and rendered as a text block on every detail page. + Confirmed against live source — the column does **not** exist, so this carries a real EF migration + (Daniel-gated apply) + DTO/converter/write-path/CMS plumbing. Lands as schema slice 11.G; render + rides 11.A + a Session/Mix touch. (Focused addition, 2026-06-15.) ### 7.2 Still open (need Daniel — recommendations given) @@ -824,6 +1004,10 @@ works on first ship of 11.A rather than as a later retrofit). all media; per-track embed stays where a track is the subject.* Trivial either way. 5. **`/genres` fate (§4.3).** Already nav-demoted; Archive has genre filtering. Retire `/genres` too? *Out of stated scope — flag as adjacent, low urgency.* **Not in Phase 11** unless Daniel pulls it. +6. **Description render: plain text or markdown? (§3d.4).** *Recommend plain text with preserved line + breaks (`white-space: pre-line`) for v1 — no markdown dependency, matches "free-text field."* Trivial + to revisit if Daniel wants formatting. Also minor: the `HasMaxLength` ceiling for the column (§3d.3 + step 1) — recommend generous (2000–4000) for paragraph prose; not a decision, just pick and note. ### 7.3 Small things to get right (not decisions — implementer notes) @@ -836,6 +1020,13 @@ works on first ship of 11.A rather than as a later retrofit). player raises a track-ended event or add one (the single new player-side surface — §3c.2). - **Year-only display (§3.1).** The Cut header shows just the year; `MixDetail`/`SessionDetail` show "MMMM yyyy". Don't copy the month-year format into the Cut header. +- **Description is a base field, not medium-specific (11.G).** Put the CMS input in + `AlbumHeaderFields` (the base-fields block alongside Genre/Release Date), **not** in `MediumFields`. + Thread the write wherever `Genre` already threads (entity → DTO → converter → `UpdateTrackMetadataRequest` + → upload form → unified services). No converter null-for-medium dance — it is uniform across media. +- **Description render degrades on null (11.G).** Most existing release rows will have `NULL` + Description until re-edited; the detail-page block must render nothing (not an empty heading) when + null. The Cut block lands in 11.A; the Session/Mix blocks are a small touch to those existing pages. --- @@ -861,6 +1052,12 @@ works on first ship of 11.A rather than as a later retrofit). - **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. +- **Uniform release data lives on the base release (§3d).** The Description column lands on the base + `ReleaseEntity` because it is uniform across media — the same logic that puts `Genre`/`ReleaseDate` + there, and the explicit Phase 9 rule that satellites are for *medium-varying* data only + (`ReleaseType` being the one volume-justified exception). Description introduces no satellite, no + medium conditional, and rides the existing release-cardinal write channel — schema growth that + honors the spine rather than bending it. --- @@ -902,3 +1099,15 @@ works on first ship of 11.A rather than as a later retrofit). - **`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. +- **No release `Description` column exists.** `ReleaseEntity` (`ReleaseEntity.cs:13-30`) carries + `Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`, + `Tracks`, `SessionMetadata?`, `MixMetadata?` — no description; `ReleaseDto` (`ReleaseDto.cs:10-33`) + mirrors it + read-model `TrackCount`; `TrackConverter` (`TrackConverter.cs:19-65`) maps every base + field both directions with no `Description` line. A grep for `[Dd]escription` across the entity + returns nothing. **Commitment 8 is a real new column** (contrast commitment 5, already-built). +- **Release-cardinal fields are written through the track update/upload path, not a release endpoint.** + There is no `PUT api/release/{id}` metadata endpoint. `BatchEdit`'s `AlbumHeaderFields` owns + `Genre`/`ReleaseDate`/etc.; on submit each track's `CmsTrackService.UpdateAsync`/`UploadTrackAsync` + carries those fields and `TrackController` (`PUT api/track/meta/{id}`) projects the release-cardinal + ones onto the linked release. **Description follows this exact channel** (`UpdateTrackMetadataRequest.cs:14-23`, + `BatchEdit.razor:380-488`, `AlbumHeaderFields.razor:16-19/80-81`).