87 Commits

Author SHA1 Message Date
daniel-c-harvey 5058c72375 fix(rcl): commit theme.js so RCL interop JS ships via MapStaticAssets
Deploy DeepDrftManager / Build & Publish (push) Successful in 2m0s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m26s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
theme/ was missing from the per-module .gitignore allowlist (only
parallax/ and knob/ were re-included), so theme.js never got committed,
was absent from publish output, and 404'd at runtime. Broaden the
allowlist to the whole DeepDrftShared.Client/wwwroot/js/ tree so every
compiled RCL interop module ships automatically.
2026-06-20 12:31:49 -04:00
daniel-c-harvey f5edcba7b2 feature: Waveform Controls Restructuring
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m27s
2026-06-20 03:12:41 -04:00
daniel-c-harvey 64e1f71e18 docs: reflect gas-lamp self-coloring in theming section
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m26s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m56s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
GasLampLit now uses an explicit #2A5C4F frame fill; the removed dark-only nav rule is no longer described as live.
2026-06-20 03:11:33 -04:00
daniel-c-harvey 7807d4ebe1 Merge theme-icon-followups into dev
Fix PlayStateIcon green-on-green chip and gas-lamp frame in dark theme.
2026-06-20 03:07:40 -04:00
daniel-c-harvey 4410132409 docs: correct PlayStateIcon compiled-selector specificity tuple (0,4,0) to (0,5,0)
The [b-xxx] Blazor scope attribute is a fifth class/attribute simple selector; the prior count dropped it.
2026-06-20 03:06:59 -04:00
daniel-c-harvey 00ff9e2702 fix(dark-theme): PlayStateIcon glyph beats .dd-accent-icon; GasLampLit self-colored frame
PlayStateIcon.razor.css adds a .mud-icon-root rule !important so the play chip always shows
navy on moss-green in dark. GasLampLit frame path changed from currentColor to #2A5C4F;
dead nav dark rule removed.
2026-06-20 03:03:18 -04:00
daniel-c-harvey bb086e5869 docs: update Provision User nav target to /useradmin/users/new (AuthBlocks 10.3.37) 2026-06-20 02:53:53 -04:00
daniel-c-harvey 674d772986 Merge p19-w6-authblocks-1037-adopt into dev (adopt AuthBlocks 10.3.37: account-creation normalization + paged-route null-guard fix; repoint Provision User nav) 2026-06-20 02:39:58 -04:00
daniel-c-harvey ee296db7f6 Merge theme-accent-icon-consolidation into dev
Consolidate per-site dark-icon overrides into reusable .dd-accent-icon treatment; fix hero glyphs in dark.
2026-06-20 02:35:08 -04:00
daniel-c-harvey 8a4da2f0b9 chore: bump Cerebellum.AuthBlocks to 10.3.37 in DeepDrftAPI
Picks up the server-side null-guard fix in RouteHelpers.GetPage/GetAll and UserService.GetPage, resolving the ArgumentNullException on the CMS User Accounts and Registrations pages.
2026-06-20 02:33:38 -04:00
daniel-c-harvey c28a2b1cf5 docs: correct specificity arithmetic and spinner-clause accuracy in .dd-accent-icon comments
Glyph rule is (0,3,0) > (0,1,0) — beats .mud-secondary-text on specificity, not source order.
ReleaseHeroOverlay spinner comment now distinguishes dead glyph clauses from the live spinner clause that produced the intentional light delta.
2026-06-20 02:32:12 -04:00
daniel-c-harvey 1427c92092 feat(manager): adopt AuthBlocks.Web 10.3.37; repoint Provision User nav to /useradmin/users/new
10.3.37 retires /account/superregister in favour of the new canonical /useradmin/users/new route. Bump the package and update the CmsLayout nav link accordingly.
2026-06-20 02:31:38 -04:00
daniel-c-harvey 2fbb1c9b95 fix(theme): green hero Share/Play/Queue glyphs in dark via shared .dd-accent-icon
Fold Session/Mix hero glyphs into the reusable accent-icon treatment so they reach
the glyph (beating .mud-secondary-text) green-accent in both themes; drop the dead
wrapper white rule and the redundant dark-only hero override. Light pixel-identical.
2026-06-20 02:21:11 -04:00
daniel-c-harvey 4c56eededc Merge dark-theme-hero-buttons into dev
Green hero Share/Play/Queue, lava-lamp, and gas-lamp affordances in dark theme.
2026-06-20 01:51:01 -04:00
daniel-c-harvey f9d99b2c98 fix: dark-theme hero buttons green in dark mode; correct source-order comment
Both ::deep and global selectors are (0,3,0); override wins on source order
(deepdrft-styles.css linked after scoped bundle in App.razor).
2026-06-20 01:49:55 -04:00
daniel-c-harvey 59608a23c5 Merge dark-theme-green-buttons into dev
Green Play/Share/Queue buttons on Cut detail in dark theme.
2026-06-20 01:30:20 -04:00
daniel-c-harvey 2ddc57edb1 fix(dark-theme): green Play/Share/Queue buttons in Cut detail
Color.Secondary renders off-white in dark mode, making the filled Play
button and the Share/Queue icon buttons in .cut-detail-actions and track
rows unreadable. Override to green (--deepdrft-primary) in dark only;
hero-overlay icons untouched.
2026-06-20 01:29:59 -04:00
daniel-c-harvey 0bb656a512 docs: log Phase 18 Wave 5 light-glass panel theming in COMPLETED
Record the new theme-aware --deepdrft-panel-* token family making the queue,
visualizer, and privacy overlays light-glass in light theme (dark-glass unchanged in
dark), and the lifted dark-glass exemption.
2026-06-20 01:12:04 -04:00
daniel-c-harvey bb5a1fcad4 fix: Privacy Message 2026-06-20 01:10:19 -04:00
daniel-c-harvey 896b37792e Merge light-glass-panels into dev
Queue, waveform-visualizer control deck, and privacy overlays now render as light
translucent glass with legible dark text in light theme via a new theme-aware
--deepdrft-panel-* token family; dark-glass charcoal unchanged in dark theme.
Lifts the prior dark-glass exemption for these three panels.
2026-06-20 01:08:30 -04:00
daniel-c-harvey 2619fc67c8 fix: wire --deepdrft-panel-text-muted into queue rows; refresh stale light/dark comments
Replace opacity-reduced color on .deepdrft-queue-position and .deepdrft-queue-artist with
var(--deepdrft-panel-text-muted) so the token earns its place in the family.
Update .wvc-section-label and .waveform-visualizer-control-icon comments to reflect
theme-aware (not static-light) behavior.
2026-06-20 01:06:58 -04:00
daniel-c-harvey 4c14c67c33 feat(theme): light-glass panels in light theme
Queue, visualizer control deck, and privacy overlays now bind a theme-aware
--deepdrft-panel-* family (surface/text/text-muted/border/row-hover): light
translucent glass with dark text in light theme, unchanged dark-glass charcoal
in dark. Tokens re-declared in body.deepdrft-theme-dark for the body-portaled overlays.
2026-06-20 00:59:22 -04:00
daniel-c-harvey 494668bf24 Merge p19-w5-mailtrap-testinbox into dev (wire optional Mailtrap TestInbox sandbox routing in DeepDrftAPI) 2026-06-20 00:36:11 -04:00
daniel-c-harvey c4e22c706c docs: record sponsor approval of NewUser normalization decisions
Mark brief §5 decisions resolved (all recommendations accepted 2026-06-20): NewUser canonical for direct provision, SuperRegister deleted + redirected, Registration label tidied.
2026-06-20 00:34:44 -04:00
daniel-c-harvey c747f3200f docs: clarify TestInbox placeholder in authblocks.example.json
Empty string gave no hint what value is expected; <sandbox-id> signals the Mailtrap sandbox inbox ID that must be supplied.
2026-06-20 00:34:41 -04:00
daniel-c-harvey 1dd1646cce docs: record popover-surface retune and portaled-popover body-class bridge
Note the 4%/bluer-navy --deepdrft-popover-surface values, the new
--deepdrft-popover-surface-dark source token, the theme TS interop module, and the
<body>-class bridge in CLAUDE.md; log Phase 18 Wave 4 in COMPLETED.md.
2026-06-20 00:32:13 -04:00
daniel-c-harvey 6bbec2fc8e Merge popover-surface-retune into dev
Retune public-site popover surfaces: light reads as a near-page-background light
surface (8%->4% navy), dark skews bluer (navy-mid + green-accent). Root cause: popovers
portal to <body>, outside the theme wrapper; MainLayout now stamps the theme class on
<body> via a TS interop helper so portaled popovers receive the dark token.
2026-06-20 00:28:20 -04:00
daniel-c-harvey 0c22ce8f09 docs: add AuthBlocks NewUser/SuperRegister normalization team brief
Brief the AuthBlocks team to make NewUser the canonical direct-provision page (absorbing SuperRegister) and keep Registration as the invite flow.
2026-06-20 00:28:06 -04:00
daniel-c-harvey 67645cfd05 wire Mailtrap TestInbox config in DeepDrftAPI
Read AuthBlocks:Email:TestInbox from config (no throw — optional sandbox key). Add TestInbox placeholder to authblocks.example.json.
2026-06-20 00:27:01 -04:00
daniel-c-harvey 2591710f09 refactor: replace eval dark-mode body-class with TS theme interop helper
Extracts setBodyThemeClass into DeepDrftShared.Client/Interop/theme/theme.ts;
MainLayout lazy-imports the compiled module and calls it, matching the
established knob/parallax IJSObjectReference pattern. DisposeAsync added.
2026-06-20 00:26:52 -04:00
daniel-c-harvey 30999b038c fix: gate OnAfterRenderAsync body-class JS call; hoist dark popover token
Only stamps body class on firstRender or _isDarkMode change; adds base call.
Hoists duplicate dark popover mix value to --deepdrft-popover-surface-dark in :root;
both .deepdrft-theme-dark and body.deepdrft-theme-dark reference it via var().
2026-06-20 00:21:53 -04:00
daniel-c-harvey b5106d090f fix: popover surface — body-class bridge for portal scope, retune light/dark
MudBlazor popovers portal to <body>, outside the theme wrapper, so the dark token
was unreachable. MainLayout now stamps deepdrft-theme-dark on <body>. Light: 8%->4%
navy (near page background); dark: navy-mid + 20% green-accent (bluer).
2026-06-20 00:15:42 -04:00
daniel-c-harvey a2ed334d0d docs: mark ModelView DI briefs resolved (shipped in BlazorBlocks 10.3.33 / AuthBlocks 10.3.36) 2026-06-19 23:58:14 -04:00
daniel-c-harvey 9300c794b4 Merge p19-w4-authblocks-1036-bump into dev (AuthBlocks 10.3.36: JWT refresh fix + ModelView DI fix; drop stopgap) 2026-06-19 23:57:08 -04:00
daniel-c-harvey 95dd48018a chore: bump AuthBlocks to 10.3.36, drop EditModalSaveContextHolder stopgap
10.3.36 fixes JWT refresh for idle sessions and registers EditModalSaveContextHolder via AddBlazorBlocksWeb() — making the manual stopgap in DeepDrftManager/Program.cs redundant. BlazorBlocks direct refs (10.3.30) resolved without conflict; left unchanged.
2026-06-19 23:54:10 -04:00
daniel-c-harvey c21b85afdf docs: note BatchUpload captures user id at init to survive mid-session token expiry 2026-06-19 23:39:10 -04:00
daniel-c-harvey 234a57d6b7 Merge cms-upload-userid-capture into dev (capture upload-form user id at init so mid-session token expiry can't discard a composed release) 2026-06-19 23:28:45 -04:00
daniel-c-harvey 4bec507aab docs: split ModelView DI brief into per-team BlazorBlocks and AuthBlocks briefs
Two self-contained team briefs with explicit ship-ordering; original trimmed to an index pointing to both.
2026-06-19 23:26:56 -04:00
daniel-c-harvey a30d15f79d fix: correct BatchUpload comments — no prerender pass on this host, single init pass on live interactive circuit 2026-06-19 23:23:16 -04:00
daniel-c-harvey b90604d311 docs: add brief for upstream BlazorBlocks ModelView DI-registration fix
EditModalSaveContextHolder is required by ModelView but registered by no BlazorBlocks/AuthBlocks setup extension. Recommends AddBlazorBlocksWeb() called from ConfigureAuthServices.
2026-06-19 23:16:29 -04:00
daniel-c-harvey 77d0562b08 feature: Dark Theme Home & About Styles 2026-06-19 23:15:26 -04:00
daniel-c-harvey aeda7e67a8 Merge p19-w3-editmodal-holder into dev (register EditModalSaveContextHolder so AuthBlocks Users/Registrations pages render) 2026-06-19 23:12:36 -04:00
daniel-c-harvey bd9c67fc65 fix: capture upload-form user id at init, not submit, so token expiry mid-session can't discard a composed release 2026-06-19 23:12:26 -04:00
daniel-c-harvey 62fe27224c fix: register EditModalSaveContextHolder in DeepDrftManager DI
ModelView has a required [Inject] of this type; without it navigating to /useradmin/users or /useradmin/registrations terminated the circuit. Matches the registration pattern from SkipperHaven.
2026-06-19 23:10:08 -04:00
daniel-c-harvey 0708bb7352 docs: correct pending-registration route references to api/pendingregistration 2026-06-19 22:53:59 -04:00
daniel-c-harvey e6d5b9b77a Merge p19-w2-mailtrap-fromaddress into dev (wire AuthBlocks:Email:From so invite-email sends succeed) 2026-06-19 22:48:06 -04:00
daniel-c-harvey 04847391ad fix: wire AuthBlocks:Email:From into EmailConnection.FromAddress
Mailtrap rejected invite sends because FromAddress was never populated. Adds the missing config assignment alongside Host/Token, and documents the From key in authblocks.example.json.
2026-06-19 22:45:49 -04:00
daniel-c-harvey 3d71b6836e docs: correct Wave 2 hero detail, add Wave 3 note to Phase 18 COMPLETED entry 2026-06-19 22:08:04 -04:00
daniel-c-harvey 833b5a921e Merge p18-w3-hero-dark-legibility into dev (Phase 18 Wave 3 — hero text + button dark-mode legibility) 2026-06-19 22:05:58 -04:00
daniel-c-harvey 3bf95538bd fix: dark btn-primary hover uses green-interactive (#429d6a) not green-light (#2A5C4F) so contrast improves on hover 2026-06-19 22:05:32 -04:00
daniel-c-harvey eb7e977f3c feature: AppBar clearance & Theming 2026-06-19 22:04:57 -04:00
daniel-c-harvey 0b8593950b docs: reflect Phase 19.1/19.2 landing (CMS nav drawer + auth-state DefaultLayout) 2026-06-19 22:04:02 -04:00
daniel-c-harvey 51ac1a76de fix(dark): hero text + button legibility on navy ground (Phase 18 W3)
Bind page-text/page-text-muted tokens directly in hero base rules (drop
:global overrides); dark-mode overrides for btn-primary (green-accent fill)
and btn-ghost (white text, light border).
2026-06-19 22:00:26 -04:00
daniel-c-harvey 949bccfb8e Merge p19-w1-t2-public-route-layout into dev (auth-state-driven DefaultLayout for public CMS routes) 2026-06-19 21:57:44 -04:00
daniel-c-harvey cfaf63468d Merge p19-w1-t1-cms-nav-drawer into dev (CMS nav drawer surfacing AuthBlocks user-admin + SuperRegister) 2026-06-19 21:57:37 -04:00
daniel-c-harvey d6dcd82a53 fix: gate SuperRegister nav link to UserAdmin role
Provision User nav link was visible to all authenticated CMS users but its target page is UserAdmin-gated. Wraps the MudNavLink in HierarchicalRoleAuthorizeView matching the UserAdminMenu pattern.
2026-06-19 21:57:00 -04:00
daniel-c-harvey 3485acf3a8 feat: auth-state-driven DefaultLayout for CMS public routes
Resolve Routes.razor DefaultLayout from cascaded AuthenticationState so unauthenticated AuthBlocks pages (/account/login, /account/register) render in lean CmsHomeLayout instead of the authenticated CmsLayout shell.
2026-06-19 21:16:42 -04:00
daniel-c-harvey c04c2a9e98 docs: reflect Phase 18 landing; fix palette-file claim in CLAUDE.md 2026-06-19 21:16:40 -04:00
daniel-c-harvey f1276faabc feat(cms): add nav drawer to CmsLayout
Add a MudDrawer with app-bar toggle linking Catalogue, Releases, Upload, SuperRegister, and the self-gating UserAdminMenu fragment so user-admin pages are reachable.
2026-06-19 21:06:47 -04:00
daniel-c-harvey 6029e226d5 Merge p18-w2-theme-followups into dev (Phase 18 Wave 2 — appbar navy, dark hero legibility, true page ground, green-on-green play chip) 2026-06-19 21:01:24 -04:00
daniel-c-harvey 135cc48301 fix: correct AppbarBackground dark-mode comment — appbar is lighter than #0D1B2A page ground, not the ground itself 2026-06-19 21:00:44 -04:00
daniel-c-harvey 54766fd5fc docs: correct Phase 19 to CMS-only host model (drop DeepDrftPublic track)
All three AuthBlocks account paths live on DeepDrftManager; public registration is an unauthenticated CMS route like the CMS login. Path 2 reduces to a single auth-state-driven DefaultLayout fix (SkipperHaven pattern).
2026-06-19 20:46:14 -04:00
daniel-c-harvey fcc95b9195 style: Phase 18 Wave 2 — appbar navy, dark hero legibility, true page ground, green-on-green play chip 2026-06-19 20:32:21 -04:00
daniel-c-harvey 042641d841 docs: expand Phase 19 to all three AuthBlocks registration paths + reset brief
Cover admin provision-now, public self-service redeem, and admin invite-by-email across CMS + public-site tracks. Add standalone AuthBlocks password-reset team brief.
2026-06-19 19:18:53 -04:00
daniel-c-harvey 0358df82ac feat: Player & Menu Styles 2026-06-19 19:18:40 -04:00
daniel-c-harvey 0f7088fe86 Merge p18-w1-theme-dark-remediation into dev (Phase 18 dark-mode token pass) 2026-06-19 19:12:26 -04:00
daniel-c-harvey 5408d0779c fix: scope play-glyph override to dark mode, fix connect-option hover, tokenize bio placeholder, correct popover comment 2026-06-19 19:04:05 -04:00
daniel-c-harvey abe94953b9 docs: add Phase 19 user-management CMS wiring plan + product note 2026-06-19 19:02:40 -04:00
daniel-c-harvey 03fdcda054 style: theme-aware token pass for dark-mode surfaces (Phase 18)
Re-point neutral page surfaces, play-chip, and default popover from constant brand tokens to theme-aware aliases defined twice in deepdrft-tokens.css. Decorative navy/green sections and bespoke dark-glass panels untouched. Appbar-navy symptom deferred (palette C#, out of CSS scope).
2026-06-19 18:12:35 -04:00
daniel-c-harvey 5298cab9b1 feature: Re-enable Dark Mode Toggle & App Bar Styles & Mobile App Bar Fixes
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m9s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m31s
2026-06-19 17:48:26 -04:00
daniel-c-harvey e05d93a67b docs: document upload staging directory and Upload:StagingPath config 2026-06-19 17:45:52 -04:00
daniel-c-harvey fd4fdd2624 docs: add Phase 18 theme/dark-mode remediation plan + product note 2026-06-19 17:41:11 -04:00
daniel-c-harvey 639f4741e6 Merge upload-temp-disk-fix into dev (stage large audio uploads on data disk instead of /tmp) 2026-06-19 17:37:26 -04:00
daniel-c-harvey d7071fdbc2 fix: always delete staging file on mid-copy abort
Build the staging path before the copy in both UploadTrack and ReplaceAudio so the finally block deletes it on cancellation or IO error, not only on success.
2026-06-19 17:36:06 -04:00
daniel-c-harvey 37cf19c405 fix: stage audio uploads on data disk instead of /tmp
Relocate both the framework multipart buffer (via ASPNETCORE_TEMP) and the controller staging file to a configurable data-disk directory, so large WAV/FLAC/MP3 uploads no longer fail on the host's small tmpfs.
2026-06-19 17:25:51 -04:00
daniel-c-harvey 37bbfb947f docs: note footer PRIVACY button + centered MudOverlay privacy modal 2026-06-19 17:09:37 -04:00
daniel-c-harvey 261b11436e Merge privacy-footer-overlay into dev (PRIVACY footer button + centered overlay note)
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m56s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-19 17:02:17 -04:00
daniel-c-harvey 280dbbcbc9 style: DRY footer btn CSS, add trailing newline, drop wrong section ordinal 2026-06-19 16:59:01 -04:00
daniel-c-harvey ce17a685e0 docs: reflect Phase 17 Wave 17.3 landing; Phase 17 complete 2026-06-19 16:48:48 -04:00
daniel-c-harvey 64379c8901 feat: move footer privacy note behind PRIVACY overlay button 2026-06-19 16:48:46 -04:00
daniel-c-harvey 1f8802363c Merge p17-w3-embed-panel into dev (Phase 17 Wave 17.3: Fixed embed queue panel + collapse/resize handshake) 2026-06-19 16:38:38 -04:00
daniel-c-harvey 58cdb4d9dc fix: isolate multi-embed resize handshake with per-snippet token
ForRelease mints a per-call token used as the iframe id and threaded into the src as EmbedId; the host script matches on it so multiple embeds resize independently. ForTrack unchanged.
2026-06-19 16:32:59 -04:00
daniel-c-harvey 97cce691db docs: document upload duplicate-detection rule, release/exists endpoint, and FindOrCreateRelease WasCreated contract 2026-06-19 16:25:50 -04:00
daniel-c-harvey d0be26bb3e Merge upload-duplicate-detection into dev (block duplicate-release uploads by title+artist) 2026-06-19 16:22:28 -04:00
daniel-c-harvey 466084b5a3 feat: Phase 17.3 — Fixed embed queue panel with collapse/expand iframe resize (OQ1 Option A)
Read-only inline queue panel below the release embed's player bar; row-jump reuses PlayRelease. ForRelease mints a taller iframe plus a postMessage resize listener for the collapse toggle; ForTrack unchanged.
2026-06-19 16:21:45 -04:00
daniel-c-harvey 558ff4b4c6 fix: close TOCTOU in CREATE path; add anti-forgery, loose-track, and case-sensitivity tests
FindOrCreateRelease now returns (ReleaseDto, bool WasCreated); the CREATE path in UploadAsync
rejects WasCreated=false as a duplicate rather than silently attaching on a lost race.
2026-06-19 15:55:08 -04:00
daniel-c-harvey bd85507308 Block duplicate-release uploads by (title, artist): pre-flight check + server 409 backstop, with within-batch Cut attach via releaseId 2026-06-19 15:44:41 -04:00
70 changed files with 3945 additions and 433 deletions
+4 -2
View File
@@ -317,5 +317,7 @@ Database/Vaults/*
!DeepDrftPublic.Client/wwwroot/js/*.js
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
# gitignored TS output is absent when manifest is generated, so absent from publish output.
!DeepDrftShared.Client/wwwroot/js/parallax/
!DeepDrftShared.Client/wwwroot/js/knob/
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
# any added later) ships, rather than maintaining a per-module allowlist.
!DeepDrftShared.Client/wwwroot/js/
!DeepDrftShared.Client/wwwroot/js/**
+7 -4
View File
@@ -10,7 +10,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
@@ -76,17 +76,20 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
### Theming and dark mode
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
### TypeScript interop, not raw JS
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"``outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"``outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
## Development Commands
@@ -126,7 +129,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+2 -2
View File
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
- Real per-user accounts (`ApplicationUser` table). No shared password.
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
+67
View File
@@ -6,6 +6,73 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev (Wave 1 + Wave 2 + Wave 3).
- **What:** A DRY token pass resolving six theming symptoms (five in dark mode, one in light) that all traced to three root causes: neutral page surfaces bound to constant brand tokens, the play chip bound to a constant light-grey, and no theme-aware popover-surface token. Resolved as one coherent pass via a shared token layer rather than per-component patches.
- **Why:** Symptom consolidation and root-cause analysis showed all six symptoms shared the same underlying structure — component CSS bypassing the theme-aware alias layer and binding constant source tokens directly. A single additive token pass in `deepdrft-tokens.css` plus targeted re-pointing of consumers fixes all six without scattering dark-mode rules.
- **Shape:**
- **Token foundation (`deepdrft-tokens.css`):** Three new theme-aware token families added to `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css`, each defined in both `:root` (light) and `.deepdrft-theme-dark` (dark):
- `--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` — neutral page surface family. Light: `--deepdrft-white` / `--deepdrft-navy` / `--deepdrft-muted`. Dark: `var(--mud-palette-background)` (#0D1B2A, the true page ground) / `--deepdrft-white` / `color-mix(muted 70%, white)` — neutral sections dissolve into the site background as one continuous dark field rather than reading as raised panels.
- `--deepdrft-play-chip` / `--deepdrft-play-glyph` / `--deepdrft-play-chip-soft` — play-chip family. Light: soft-grey chip (matching prior `--deepdrft-soft`). Dark: `--deepdrft-green-accent` chip + `--deepdrft-navy` glyph (navy-on-green for solid chips); `--deepdrft-play-chip-soft` is `color-mix(green-accent 30%, transparent)` (the player-bar translucent override).
- `--deepdrft-popover-surface` — popover surface. Light: `color-mix(navy 8%, white)` soft desaturated-navy wash. Dark: `#162437` (pixel-identical to `DeepDrftPalettes.Dark.Surface` — dark popovers unchanged, only light is retoned).
- **Neutral-surface inversion (T2):** `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css` re-pointed from constant `--deepdrft-white`/`--deepdrft-navy` to `--deepdrft-page-surface`/`--deepdrft-page-text`. Decorative navy/green sections (`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) untouched — classification encoded in which token each section binds.
- **Play-chip theming (T3):** `PlayStateIcon.razor.css` `.icon-container` re-pointed to `--deepdrft-play-chip`; glyph to `--deepdrft-play-glyph`. Player-bar context overrides chip to `--deepdrft-play-chip-soft` (translucent green wash). Light-mode parity and connect-option hover also corrected.
- **Popover surface (T4):** `deepdrft-styles.css` binds `--deepdrft-popover-surface` to the MudBlazor default popover surface. Bespoke dark-glass panels (`--deepdrft-panel-ground`) untouched.
- **Wave 2 refinements (on top of T1T4):** App bar background moved to navy (`#112338`) from near-black (`#0D1B2A`). Neutral page surfaces re-pointed to `var(--mud-palette-background)` (`#0D1B2A`) as the true dark ground — sections dissolve into the body background rather than reading as navy-mid raised panels (resolves Wave 1's open question in favour of ground). Dark-mode hero legibility (superseded in Wave 3 — see below). Play-glyph settled on navy-on-green (solid chips) and green-on-green (player bar, via `--deepdrft-play-chip-soft`).
- **Wave 3 — hero dark-mode legibility fix:** `DeepDrftHero.razor.css` hero text re-worked to bind theme-aware tokens directly in the base rules rather than via `:global(.deepdrft-theme-dark)` overrides (matching the About page's proven pattern). `.hero-title` and `.hero-desc` now bind `--deepdrft-page-text` directly; `.hero-subtitle` (previously bound to the constant `--deepdrft-muted`) now binds `--deepdrft-page-text-muted`, making it theme-aware for the first time. Only `.hero-title em` retains an explicit dark override (`:global(.deepdrft-theme-dark) .hero-title em``--deepdrft-green-accent`, lifting the low-contrast `--deepdrft-green` on the dark ground). Global hero-button dark treatment added to `deepdrft-styles.css`: `.deepdrft-theme-dark .btn-primary``--deepdrft-green-accent` fill + `--deepdrft-navy` text (hover: `--deepdrft-green-interactive`); `.deepdrft-theme-dark .btn-ghost``--deepdrft-page-text` color + `--deepdrft-border-light` border.
- **Open questions resolved:** Dark neutral surface = ground (continuous field, `--mud-palette-background`) — not elevated navy-mid. Popover target: `color-mix(navy 8%, white)` in light; dark binds `#162437` (MudBlazor dark Surface) unchanged.
- **Design memo:** `product-notes/theme-dark-mode-remediation.md`.
### Phase 18 — Wave 4 — Popover-surface retune + portaled-popover body-class bridge (landed 2026-06-20)
**Landed:** 2026-06-20 on dev.
- **What:** Follow-on retune of `--deepdrft-popover-surface` values and a root-cause fix for portaled MudBlazor popovers that were never reaching the dark token.
- **Why:** Wave 13 shipped `--deepdrft-popover-surface` light at `color-mix(navy 8%, white)` (too saturated — read as a grey slab) and dark at flat `#162437`. More importantly, MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark token never applied to them at all. Both needed fixing as a pair.
- **Shape:**
- **Token retune (`deepdrft-tokens.css`):** Light value changed from 8% → 4% navy mix (near-page-background, clearly light). Dark value changed from `#162437` to `color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%)` — a bluer navy with a slight green accent. Dark value hoisted into a new source token `--deepdrft-popover-surface-dark` (defined once in `:root`), referenced by both the `.deepdrft-theme-dark` wrapper block and a new `body.deepdrft-theme-dark` block so portaled content is reached from either selector.
- **Portaled-popover body-class bridge (`MainLayout.razor` + new TS module):** `MainLayout.razor` now stamps/removes `deepdrft-theme-dark` on `<body>` after each render via a new `DeepDrftShared.Client/Interop/theme/theme.ts` module exporting `setBodyThemeClass(isDark: boolean)`. Lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`. Call is gated to fire only on first render or when `_isDarkMode` changes (`_lastAppliedDarkMode` comparison) — no redundant JS calls on unrelated re-renders. `IJSObjectReference _themeModule` is disposed in `DisposeAsync` to clean up the module reference when the circuit tears down.
### Phase 18 — Wave 5 — Glass-panel theme-aware token family (landed 2026-06-20)
**Landed:** 2026-06-20 on dev.
- **What:** The three `MudOverlay`-based glass panels — the queue panel (`.deepdrft-queue-modal`), the waveform visualizer control deck, and the privacy modal — now render as a light translucent glass with legible dark text in light theme, while remaining the existing dark-glass charcoal in dark theme. Dark mode is visually unchanged; a latent white-on-light bug in the inline embed queue row was incidentally fixed by the token flip.
- **Why:** Prior to this wave, all three panels were bound to the constant `--deepdrft-panel-ground` token, exempting them from the theme-aware alias layer established in Waves 13. In light theme this produced white text on a near-white glass surface — unreadable. The panels needed their own theme-aware family (separate from `--deepdrft-popover-surface`, which targets MudBlazor default popovers) and the same `body.deepdrft-theme-dark` portal-scope treatment introduced for popovers in Wave 4.
- **Shape:**
- **New token family (`deepdrft-tokens.css`):** `--deepdrft-panel-surface` / `--deepdrft-panel-text` / `--deepdrft-panel-text-muted` / `--deepdrft-panel-border` / `--deepdrft-panel-row-hover` — each defined in `:root` (light values: translucent glass with dark text), `.deepdrft-theme-dark` (dark-glass charcoal with light text, sourced from the existing `--deepdrft-panel-ground` constant), and `body.deepdrft-theme-dark` (same dark values re-declared so the tokens resolve correctly when the panels portal to `<body>` via `MudOverlay`).
- **Consumer re-pointing:** The three panels and their descendants (queue rows, visualizer deck, privacy modal) previously bound `--deepdrft-panel-ground` directly; they are now re-pointed to the appropriate `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` aliases.
- **Exemption lifted:** This deliberately removes the previously-documented exemption of these panels from the theme-aware layer. `--deepdrft-panel-ground` is now consumed only as the dark-theme value of `--deepdrft-panel-surface`, not directly by any component CSS.
---
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The Fixed (embed) mode queue panel and the OQ1 Option-A iframe resize handshake. Release embeds now render an always-shown, read-only queue panel below the player-bar controls; the Queue button collapses/expands that panel and posts the iframe's new height to the host page so the outer `<iframe>` element resizes to match. Single-track embeds (TrackEntryKey mode) have no queue, no panel, and no Queue button — unchanged compact behaviour. Phase 17 is now complete (all four waves landed).
- **Why:** Phase 11 wave 11.F armed release embeds with a queue (skip navigation, auto-advance), but the viewer had no way to see or jump within the queue. Wave 17.3 surfaces it in Fixed mode — read-only because a shared embed is not an editable playlist — and resolves OQ1 (Option A confirmed feasible: `postMessage` resize degrades gracefully if the host strips the script).
- **Shape:**
- **Fixed embed queue panel** (`AudioPlayerBar.razor`): rendered conditionally on `ShowFixedPanel && _fixedPanelOpen` inside `.deepdrft-queue-embed-panel`; hosts `<QueueList Items="QueueItems" CurrentIndex="QueueCurrentIndex" Editable="false" OnJump="@OnQueueJump" />`. Read-only: no drag handles, no remove buttons. Row-jump (OQ2) calls `PlayRelease(Items, index)` — coherent from the armed-but-not-started state (`PlayRelease` already clears `IsArmed` and materializes a defensive copy).
- **Queue button in Fixed mode** (`PlayerTransportZone`): toggles `_fixedPanelOpen`; triggers a height post after the panel renders. Gated on `ShowFixedPanel` so single-track embeds see no button.
- **`EmbedSnippetBuilder.cs`** (`DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs`): `ForRelease` now mints a per-snippet random token (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`). Token is used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}`. Taller iframe height (release: 384 px vs. track: 196 px). Carries a host-side `<script>` listener that matches incoming `{type:"deepdrft-embed-resize", embedId}` messages against the snippet's own token and sets `iframe.style.height` — multiple release embeds on one host page resize independently (no cross-talk). Degrades to Option B if the host strips the script (panel still works inside the iframe at expanded height). `ForTrack` is unchanged (compact height 196 px, no script, no id token).
- **`embed-frame.ts`** (`DeepDrftPublic/Interop/embed/embed-frame.ts`; compiled output gitignored): new TypeScript interop module. Reads `EmbedId` from `window.location.search` once at module load; exports `postHeight(element: HTMLElement)` — measures the player element's rendered height (`Math.ceil(getBoundingClientRect().height) + 2`), builds `{type:"deepdrft-embed-resize", height, embedId?}` payload (omits `embedId` when absent for backward-compatible degradation), and calls `window.parent.postMessage(payload, "*")`. No-ops when not framed (`window.parent === window`) or the element is unmeasurable.
- **CSS** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): new `deepdrft-queue-embed-panel` and related `deepdrft-` embed-panel classes for the fixed queue panel chrome.
- **Tests** (`EmbedSnippetBuilderTests`): height divergence (ForRelease taller than ForTrack), ForTrack-unchanged (height 196, no script), id uniqueness (two ForRelease calls yield distinct ids), id/script-token consistency (iframe id matches token in script), EmbedId-in-src (token appears as `EmbedId=` in the iframe src).
---
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
+21 -7
View File
@@ -16,7 +16,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
- `Controllers/TrackController.cs`: Track endpoints (see below).
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
@@ -120,6 +120,17 @@ Admin backfill: for every track whose `DurationSeconds` SQL column is still null
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
- Returns 200 on success. Returns 500 if the backfill operation fails.
### GET api/track/release/exists ([ApiKeyAuthorize])
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Query parameters**:
- `title` (string, required): the release title to check.
- `artist` (string, required): the artist name to check.
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. Returns 500 on query error.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
@@ -156,10 +167,11 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -177,7 +189,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
@@ -367,6 +379,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -389,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
## Configuration files
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
- `Logging`: standard ASP.NET structure.
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
+94 -50
View File
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -41,9 +43,48 @@ public class TrackController : ControllerBase
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
private string BuildStagingPath(string uploadExtension) =>
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
// must delete it in a finally block; separating path generation from the copy ensures the finally
// guard fires even when CopyToAsync throws before returning.
private async Task StageUploadAsync(
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
{
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
@@ -96,6 +137,37 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
[ApiKeyAuthorize]
[HttpGet("release/exists")]
public async Task<ActionResult> ReleaseExists(
[FromQuery] string? title,
[FromQuery] string? artist,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return BadRequest("title and artist are both required");
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
return StatusCode(500, "Failed to check release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
// GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
@@ -220,6 +292,7 @@ public class TrackController : ControllerBase
[FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber,
[FromForm] long? releaseId,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
@@ -287,23 +360,15 @@ public class TrackController : ControllerBase
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
// generate our own path preserving the validated .wav/.mp3/.flac extension.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
@@ -315,6 +380,7 @@ public class TrackController : ControllerBase
parsedReleaseType,
parsedMedium,
resolvedTrackNumber,
releaseId,
cancellationToken);
if (!result.Success || result.Value is null)
@@ -322,14 +388,19 @@ public class TrackController : ControllerBase
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
// A cardinality rejection is a well-formed request that violates a domain rule, so it
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is
// stripped so the client sees only the human-readable detail.
// A cardinality or duplicate-release rejection is a well-formed request that violates a
// domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
// The marker is stripped so the client sees only the human-readable detail.
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
}
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
}
return StatusCode(500, error);
}
@@ -343,17 +414,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -529,21 +590,14 @@ public class TrackController : ControllerBase
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
@@ -566,17 +620,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
+7 -1
View File
@@ -15,7 +15,13 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.37" />
</ItemGroup>
<ItemGroup>
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
<InternalsVisibleTo Include="DeepDrftTests" />
</ItemGroup>
<ItemGroup>
+13
View File
@@ -0,0 +1,13 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
/// </summary>
public class UploadSettings
{
public string? StagingPath { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
/// </summary>
public sealed record UploadStagingDirectory(string Path);
}
+5 -2
View File
@@ -103,10 +103,13 @@ builder.Services.AddAuthBlocks(options =>
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
options.AdminUserSettings = new AdminUserSettings
{
+104 -28
View File
@@ -25,6 +25,16 @@ public class UnifiedTrackService
/// follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
/// <summary>
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
/// detail follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
@@ -64,33 +74,66 @@ public class UnifiedTrackService
ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber,
long? releaseId,
CancellationToken ct)
{
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
// find path can violate: a release that does not yet exist has zero tracks and admits its
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
// a future bounded medium is covered by the same line.
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
// orphans audio. Two paths:
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
// duplicate and is blocked (409).
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
// to the release row 1 just created. No (title, artist) lookup — the release id is
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
// future bounded medium is covered by the same line).
ResolvedRelease? resolved = null;
if (!string.IsNullOrWhiteSpace(album))
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
if (releaseId is { } attachId)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!attachPeek.Success)
{
var error = attachPeek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
if (peek.Value is { } existing)
{
var cardinality = MediumRules.CardinalityOf(existing.Medium);
if (existing.TrackCount + 1 > cardinality.Max)
// The attach target must be the same release the natural key resolves to — a guard against
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
if (attachPeek.Value is not { } target || target.Id != attachId)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
$"'{existing.Title}' already has one — edit the existing track or choose a different release.");
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
"Start the upload again.");
}
var cardinalityCheck = CheckCardinality(target);
if (cardinalityCheck is { } violation)
return ResultContainer<TrackDto>.CreateFailResult(violation);
resolved = new ResolvedRelease(target.Id);
}
else
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
// edits or appends to an existing release.
if (peek.Value is { } existing)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
// resolved stays null → FindOrCreateRelease below creates the release.
}
}
@@ -109,9 +152,12 @@ public class UnifiedTrackService
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
long? resolvedReleaseId = resolved?.Id;
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
{
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
// mints the release. (The attach path already resolved the id from the pre-check above and
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
var releaseData = new ReleaseDto
{
Title = album,
@@ -124,13 +170,13 @@ public class UnifiedTrackService
CreatedByUserId = createdByUserId,
};
// Medium (like every other field in releaseData) applies only when this upload CREATES the
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
// subsequent track add: medium is a release-level property, changed only via the edit path
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
// rather than silently attaching, keeping the DB unique index as the final safety net.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null)
if (!releaseResult.Success)
{
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
@@ -139,11 +185,21 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
releaseId = releaseResult.Value.Id;
var (resolvedRelease, wasCreated) = releaseResult.Value;
if (!wasCreated)
{
// The winning concurrent upload created this release between our peek and our insert.
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
resolvedReleaseId = resolvedRelease.Id;
}
var trackDto = TrackConverter.Convert(unpersisted);
trackDto.ReleaseId = releaseId;
trackDto.ReleaseId = resolvedReleaseId;
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
var saveResult = await _sqlTrackService.Create(trackDto);
@@ -166,6 +222,26 @@ public class UnifiedTrackService
return saveResult;
}
// The release a track resolved onto before the vault write. A null Id is the create path (mint
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
private readonly record struct ResolvedRelease(long Id);
// The cardinality guard shared by the attach path and (historically) the create path: a release
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
// message, or null when the add is within limits. The create path never trips this (a brand-new
// release has zero tracks and admits its first), so only the attach path calls it today.
private static string? CheckCardinality(ReleaseDto release)
{
var cardinality = MediumRules.CardinalityOf(release.Medium);
if (release.TrackCount + 1 > cardinality.Max)
{
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
}
return null;
}
/// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
+32
View File
@@ -47,9 +47,41 @@ namespace DeepDrftAPI
return db;
});
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
// on-disk copies of an upload off /tmp onto the data disk:
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
// Default location is a "staging" subdirectory beside the vaults; override with
// Upload:StagingPath in appsettings.json.
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
Directory.CreateDirectory(stagingPath);
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
// absent, so set it (and create the dir) before any request is served.
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
return Task.CompletedTask;
}
/// <summary>
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
/// </summary>
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
{
var path = string.IsNullOrWhiteSpace(configuredPath)
? Path.Combine(vaultPath, "staging")
: configuredPath;
return Path.GetFullPath(path);
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Tracks))
+3
View File
@@ -7,6 +7,9 @@
}
},
"AllowedHosts": "*",
"Upload": {
"StagingPath": ""
},
"CorsSettings": {
"AllowedOrigins": [
"https://localhost:12778",
@@ -8,7 +8,9 @@
},
"Email": {
"Host": "smtp.your-provider.com",
"Token": "your-email-token-here"
"Token": "your-email-token-here",
"From": "noreply@yourdomain.com",
"TestInbox": "<sandbox-id>"
},
"Admin": {
"UserName": "admin",
+1
View File
@@ -55,6 +55,7 @@ Notable repository / service methods beyond the standard CRUD:
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
+5 -1
View File
@@ -59,8 +59,12 @@ public interface ITrackService
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary>
+9 -9
View File
@@ -164,14 +164,14 @@ public class TrackManager
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
@@ -186,21 +186,21 @@ public class TrackManager
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row. Should not return null here since the
// constraint just fired, but re-throw if it does so the caller sees an error.
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
}
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
}
}
@@ -302,13 +302,13 @@ public class TrackManager
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
if (!resolved.Success || resolved.Value is null)
if (!resolved.Success)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
newTrack.ReleaseId = resolved.Value.Release.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
@using AuthBlocksWeb.Components.Layout
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
@@ -8,6 +9,10 @@
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
@@ -18,6 +23,19 @@
Color="Color.Inherit" />
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" Variant="DrawerVariant.Responsive" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="/catalogue" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Catalogue</MudNavLink>
<MudNavLink Href="/releases" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LibraryMusic">Releases</MudNavLink>
<MudNavLink Href="/tracks/upload" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Upload</MudNavLink>
<UserAdminMenu />
<HierarchicalRoleAuthorizeView RolesList="@([SystemRoleConstants.UserAdmin])">
<Authorized>
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
</Authorized>
</HierarchicalRoleAuthorizeView>
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="pt-14 pb-8">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
@@ -25,6 +43,12 @@
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
@@ -146,6 +146,9 @@
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The id of the release being edited. New tracks added in this session attach to it via the upload
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
private long? _releaseId;
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
@@ -214,6 +217,10 @@
}
var release = tracks[0].Release;
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
_releaseId = release?.Id ?? tracks[0].ReleaseId;
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
@@ -592,6 +599,7 @@
_releaseType,
trackNumber,
_medium,
_releaseId,
progress);
if (!uploadResult.Success || uploadResult.Value is null)
@@ -129,6 +129,11 @@
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
private bool _heroWarningAcknowledged;
// Captured once at component initialization on the live interactive circuit, while the token
// is known-good, so a mid-session token expiry at submit time cannot discard a long-composed
// release. Only assigned when the id parses successfully.
private long? _createdByUserId;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
@@ -156,6 +161,19 @@
}
}
protected override async Task OnInitializedAsync()
{
// Capture the user id once at load, while the token is known-good. The CMS host runs with
// prerender: false (InteractiveServer), so this is the single init pass — auth state is
// fully available. The page is [Authorize]-gated, so the parse should always succeed.
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (long.TryParse(userIdValue, out var userId))
{
_createdByUserId = userId;
}
}
// Switching to a single-track medium collapses any multi-track selection to the first row so the
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
// declaration the upload service enforces, so the form and the domain cannot drift.
@@ -275,13 +293,12 @@
}
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var createdByUserId))
if (_createdByUserId is not long createdByUserId)
{
// The page is gated by [Authorize] under the Admin role, so a missing or
// unparseable id here is a configuration bug, not normal client state.
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
// _createdByUserId is set at component initialization from the authenticated principal.
// A null here means the id was unavailable even at load — a genuine configuration bug,
// since the page is [Authorize]-gated.
Logger.LogError("User id was not captured at initialization — NameIdentifier claim missing or unparseable.");
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
@@ -298,6 +315,29 @@
return;
}
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
// the admin is not surprised at the end of a long upload. The server backstops this on the
// create path, but checking here keeps the failure fast and visible. The values passed match
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
if (!duplicateCheck.Success)
{
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Could not verify the release name: {checkError}";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
if (duplicateCheck.Value is { } existing)
{
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
@@ -327,6 +367,11 @@
}
int succeeded = 0, failed = 0;
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
// reaches row 2 (single-track media collapse to one row).
long? batchReleaseId = null;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
@@ -375,6 +420,7 @@
_releaseType,
trackNumber,
_medium,
batchReleaseId,
progress);
if (!result.Success || result.Value is null)
@@ -387,6 +433,15 @@
}
else
{
// Capture the release id created by the first successful row so subsequent rows
// attach to it (the within-batch multi-track Cut path). Only set once — later
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
// which is correct: a release-less upload has no within-batch release to attach to.
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
{
batchReleaseId = createdReleaseId;
}
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern BatchEdit uses.
if (_imagePath is { } imgPath)
@@ -487,7 +542,13 @@
}
else
{
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
+19 -2
View File
@@ -2,8 +2,8 @@
AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="typeof(Layout.CmsLayout)">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="@_currentLayout">
<NotAuthorized Context="authState">
@if (authState.User.Identity?.IsAuthenticated == true)
{
@@ -18,3 +18,20 @@
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthenticationState { get; set; }
private Type _currentLayout = typeof(Layout.CmsHomeLayout);
protected override async Task OnParametersSetAsync()
{
if (AuthenticationState is not null)
{
var authState = await AuthenticationState;
_currentLayout = authState.User.Identity?.IsAuthenticated == true
? typeof(Layout.CmsLayout)
: typeof(Layout.CmsHomeLayout);
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.33" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.37" />
</ItemGroup>
<ItemGroup>
@@ -68,6 +68,7 @@ public class CmsTrackService : ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
@@ -91,6 +92,9 @@ public class CmsTrackService : ICmsTrackService
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
// releaseId present → ATTACH (rows 2..N of a within-batch Cut); absent → CREATE (server rejects a
// pre-existing (title, artist) as a duplicate). Only sent when set so the form omits it on row 1.
if (releaseId is { } rid) multipart.Add(new StringContent(rid.ToString()), "releaseId");
var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
if (send.Response is not { } response)
@@ -474,6 +478,53 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
var query = $"api/track/release/exists?title={Uri.EscapeDataString(title)}&artist={Uri.EscapeDataString(artist)}";
HttpResponseMessage response;
try
{
response = await client.GetAsync(query, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release existence check ({Title}, {Artist})", title, artist);
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
// 404 is the not-found (null) case, not a failure — no release matches this (title, artist).
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release existence check failed for ({Title}, {Artist}): {Status}",
title, artist, (int)response.StatusCode);
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to check for an existing release.");
}
ReleaseDto? release;
try
{
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize ReleaseDto from release existence check");
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
}
}
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
@@ -25,6 +25,10 @@ public interface ICmsTrackService
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
/// stalled connection aborts without a fixed total-duration cap.
/// <paramref name="releaseId"/> distinguishes the two rows of a within-batch multi-track Cut: null on
/// the first row (CREATE — the server rejects a pre-existing (title, artist) as a duplicate) and the
/// id returned by that first row on rows 2..N (ATTACH — the server skips the duplicate check and adds
/// the track to the release the batch just created).
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -42,9 +46,20 @@ public interface ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default);
/// <summary>
/// Upload-form pre-flight: returns the existing release whose exact (title, artist) matches, or null
/// when none exists. Backs the duplicate block the form runs BEFORE transferring bytes, so the admin
/// is not surprised at the end of a long upload. A 404 from the API is the not-found (null) case, not
/// a failure. The match semantics are the API's <c>GetReleaseByTitleAndArtist</c> — the same read the
/// server backstop uses — so the pre-flight and the backstop agree.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default);
/// <summary>
/// Delete a track via the Content API, which removes the SQL row then the vault entry.
/// Maps a 404 to a "Track not found." failure.
+13 -3
View File
@@ -11,14 +11,14 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
@@ -41,7 +41,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)``<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)``<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. Two targets diverge in height and content (Phase 17 wave 17.3): `ForTrack(baseUri, trackEntryKey)` compact `<iframe>` at 196 px (no queue panel, no script, unchanged from before 17.3). `ForRelease(baseUri, releaseEntryKey)` taller `<iframe>` at 384 px plus a host-side `<script>` resize listener; mints a fresh random token per call (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`) used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}` — the in-iframe `embed-frame.ts` reads this token and includes it in `postMessage` payloads so the host listener can route resize messages to the correct iframe when multiple release embeds share a host page. The script matches on `embedId` and applies `iframe.style.height`; degrades safely (panel still works inside the iframe) if the host strips the script. Pure string composition — unit-testable without rendering. TypeScript counterpart: `DeepDrftPublic/Interop/embed/embed-frame.ts` (compiled output gitignored).
- `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -140,6 +140,16 @@ Component state lives in ViewModels (registered scoped in DI). Components render
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
### Interactive-accent icons (`.dd-accent-icon` / `.dd-accent-fill`)
Green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger, etc.) use a **single reusable treatment** in `deepdrft-styles.css`, not per-site dark overrides. Wrap the affordance(s) in a container carrying `.dd-accent-icon`; the rule colours the inner `.mud-icon-root` glyph green-accent (`--deepdrft-green-accent`, the brand constant — same value in both palettes) in **both** themes. Add `.dd-accent-fill` to the same container when it also holds a filled `Color.Secondary` `MudButton` whose fill must go green-accent in **dark** (dark-only — light already renders green fill + white text).
Two reasons this is needed and why it's a class, not a palette colour: (1) no MudBlazor `Color` enum is green in both themes (`Dark.Secondary` is off-white), so palette-only solutions can't express "green in both"; (2) MudBlazor stamps the standalone rule `.mud-secondary-text { color: …secondary !important }` (0,1,0) on the glyph `<svg>`, so wrapper-level overrides never reach it — the reusable rule targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important`, which beats it on specificity alone; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too: they were already green-accent in light (via `Color.Secondary``Light.Secondary`), so folding them in keeps light pixel-identical while fixing the dark over-image glyphs — they are not actually theme-divergent. **Add new green-accent icon affordances by applying this class, not by spawning a new dark override.**
**Self-themed components are authoritative over `.dd-accent-icon`.** `PlayStateIcon` owns its glyph colour inside `.icon-container` and must beat a surrounding `.dd-accent-icon` in dark — its scoped CSS rule targets `.mud-icon-root` at (0,5,0) `!important` (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) `!important`. Do not wrap a `PlayStateIcon` in `.dd-accent-icon` expecting to recolor its play-chip glyph — the play chip always shows navy (`--deepdrft-play-glyph`) against the moss-green chip in dark.
**Gas-lamp toggle is self-colored in its SVG.** `DDIcons.GasLampLit` (dark-mode icon) carries `fill="#2A5C4F"` directly on its frame path — no CSS colour override is needed. The former dark nav rule (`.deepdrft-theme-dark .dd-nav-actions .mud-icon-button`) has been removed as dead. `DDIcons.GasLamp` (light-mode icon) continues to use `currentColor` and inherits nav text colour in light (the unlit toggle is theme-divergent by design).
## Development commands
```bash
@@ -26,7 +26,7 @@ else
SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious"
ShowQueueButton="ShowQueueButton"
QueueOpen="_queueOpen"
QueueOpen="QueueButtonOpen"
QueueToggle="@ToggleQueue"
Class="transport-zone"/>
@@ -42,6 +42,23 @@ else
Class="seek-zone"/>
</div>
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@if (ShowFixedPanel && _fixedPanelOpen)
{
<div class="deepdrft-queue-embed-panel">
<QueueList Items="QueueItems"
CurrentIndex="QueueCurrentIndex"
Editable="false"
OnJump="@OnQueueJump"/>
</div>
}
@* Minimize / close — positioned absolutely top-right *@
@if (!Fixed)
{
@@ -62,8 +79,8 @@ else
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
the Fixed embed gets its own inline panel in a later wave. *@
@if (ShowQueueButton)
the Fixed embed renders its own inline panel inside the surface above. *@
@if (ShowDockedOverlay)
{
<QueueOverlay Visible="_queueOpen"
Items="QueueItems"
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private IJSObjectReference? _spacerModule;
private bool _spacerObserved;
// Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we
// measure the player's live height and post it to the host so the iframe resizes to match. The
// dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first.
private IJSObjectReference? _embedModule;
private bool _embedHeightDirty;
private bool _embedHeightPosted;
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService?.IsLoading ?? false;
@@ -64,13 +71,31 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false;
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
// Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
// skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
// single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
// the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A).
private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0;
private bool ShowQueueButton => HasQueue;
// The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead.
private bool ShowDockedOverlay => !Fixed && HasQueue;
// The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue.
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
// up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space.
private bool _fixedPanelOpen = true;
// The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the
// inline panel's expanded state. One button, mode-appropriate meaning.
private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen;
/// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
@@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await QueueService.Previous();
}
private void ToggleQueue() => _queueOpen = !_queueOpen;
// Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so
// the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in
// OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host
// listener may simply not be present (Option B's behaviour).
private void ToggleQueue()
{
if (Fixed)
{
_fixedPanelOpen = !_fixedPanelOpen;
_embedHeightDirty = true;
return;
}
_queueOpen = !_queueOpen;
}
private void CloseQueue() => _queueOpen = false;
@@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// The Fixed embed is already in normal flow — no spacer/clip needed.
// Fixed embed: post the live player height to the host so the iframe sizes to the panel. We
// post on the first render (so the host snaps to the expanded panel rather than the snippet's
// initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/
// clip here — the embed is in normal flow.
if (Fixed)
{
if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty))
{
_embedHeightDirty = false;
_embedHeightPosted = true;
await PostEmbedHeight();
}
return;
}
// For the docked player: we observe in BOTH expanded and minimized states
// so --player-height always reflects the live height of whichever element
// is visible. This keeps the WaveformVisualizer clipped to the top of
@@ -169,7 +222,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// minimized → observe _miniDock (floating FAB container, ~5660px)
// The player-spacer's .minimized class uses a hardcoded height and ignores
// the var, so publishing the FAB height here does not regress the spacer.
if (Fixed) return;
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
@@ -198,6 +250,37 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
}
// Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort:
// a missing module or a host that ignores the message just means no outer resize (Option B value).
private async Task PostEmbedHeight()
{
var module = await GetEmbedModuleAsync();
if (module is null) return;
try
{
await module.InvokeVoidAsync("postHeight", _playerRoot);
}
catch (JSException)
{
// Runtime gone or element detached mid-teardown — nothing actionable.
}
}
private async Task<IJSObjectReference?> GetEmbedModuleAsync()
{
try
{
return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/embed/embed-frame.js");
}
catch (JSException)
{
// Module failed to load — the panel still renders and toggles; only the outer resize is lost.
return null;
}
}
private async Task Expand() => await SetMinimized(false);
/// <summary>
@@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
_spacerModule = null;
}
if (_embedModule is not null)
{
try
{
await _embedModule.DisposeAsync();
}
catch (JSException)
{
// Runtime already gone (navigation/teardown) — nothing to clean up.
}
_embedModule = null;
}
}
}
@@ -42,6 +42,20 @@
right: 0.5rem;
}
/* PLAYER-BAR play-chip override (Phase 18, T3). PlayStateIcon's chip defaults to the solid
--deepdrft-play-chip (moss-green in dark) used on release heroes and Cut track rows. On the
player dock that solid green reads too hot, so here and only here swap to the
translucent --deepdrft-play-chip-soft (same green, much less opaque).
The glyph stays --mud-palette-primary (green on the soft translucent wash), giving the
preferred green-on-green look on the player bar in dark mode. */
::deep .player-surface .icon-container {
background-color: var(--deepdrft-play-chip-soft);
}
::deep .player-surface .icon-container .mud-icon-button {
color: var(--mud-palette-primary);
}
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
@@ -14,14 +14,6 @@
Color="Color.Primary"
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed || HasPrevious || HasNext)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
}
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop"
@@ -30,4 +22,12 @@
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
@if (!Fixed || HasPrevious || HasNext)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
}
</MudStack>
@@ -23,17 +23,19 @@
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
Color="Color.Primary"
Size="Size.Medium"
OnClick="QueueToggle"
aria-label="Queue"
aria-expanded="@QueueOpen"
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
<MudStack Row AlignItems="AlignItems.Center">
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
Color="Color.Primary"
Size="Size.Medium"
OnClick="QueueToggle"
aria-label="Queue"
aria-expanded="@QueueOpen"
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
</MudStack>
</MudStack>
@@ -13,7 +13,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.8rem;
display: flex;
@@ -27,7 +27,7 @@
display: block;
width: 2.5rem;
height: 1px;
background: var(--deepdrft-green-accent);
background: var(--deepdrft-green);
}
.hero-title {
@@ -36,14 +36,14 @@
font-weight: 300;
line-height: 0.92;
letter-spacing: -0.02em;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 0.5rem;
animation-delay: 0.22s;
}
.hero-title em {
font-style: italic;
color: var(--deepdrft-green);
color: var(--deepdrft-green-accent);
}
.hero-subtitle {
@@ -51,7 +51,7 @@
font-size: clamp(1rem, 2vw, 1.35rem);
font-weight: 300;
font-style: italic;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
margin-bottom: 3rem;
letter-spacing: 0.04em;
animation-delay: 0.34s;
@@ -61,7 +61,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.92rem;
line-height: 1.75;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.7;
max-width: 36ch;
margin-bottom: 3rem;
@@ -81,3 +81,11 @@
align-items: stretch;
}
}
/* Dark-mode accent override (Phase 18, Wave 3).
.hero-title and .hero-desc bind --deepdrft-page-text directly above (theme-aware).
The em italic is the only element needing an explicit dark lift:
--deepdrft-green (#1A3C34) is low-contrast on the navy ground; lift to green-accent. */
:global(.deepdrft-theme-dark) .hero-title em {
color: var(--deepdrft-green-accent);
}
@@ -2,7 +2,7 @@
display: flex;
justify-content: center;
align-content: center;
background-color: var(--deepdrft-soft);
background-color: var(--deepdrft-play-chip);
border-radius: 50%;
height: 60px;
width: 60px;
@@ -10,5 +10,27 @@
}
.icon-container:hover {
background-color: color-mix(var(--deepdrft-soft), var(--deepdrft-navy-mid) 25%);
background-color: color-mix(in srgb, var(--deepdrft-play-chip), var(--deepdrft-navy-mid) 25%);
}
/* In dark mode the chip is moss-green and MudIconButton's Color.Primary/Secondary green
glyph would vanish against it, so pin the glyph to --deepdrft-play-glyph (navy) in dark
only. In light mode the token also resolves to navy, but applying it there overrides
Color.Secondary (green-accent) on hero/row mounts a visible regression. Scoping to
.deepdrft-theme-dark preserves the MudBlazor Color prop in light and fixes only dark.
::deep reaches the portaled-in-scope MudIconButton icon, which doesn't carry this
component's scope attribute. */
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button {
color: var(--deepdrft-play-glyph);
}
/* PlayStateIcon is authoritative over its own glyph colour a surrounding .dd-accent-icon
must NOT recolor the play-chip glyph in dark. The consolidation rule is:
.dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important
After Blazor scoped-CSS compilation this rule becomes:
.deepdrft-theme-dark .icon-container[b-xxx] .mud-icon-button .mud-icon-root (0,5,0) !important
(0,5,0) beats (0,3,0) wins on specificity; !important parity is irrelevant.
Dark only: light already renders the navy glyph via the MudBlazor Color prop. */
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button .mud-icon-root {
color: var(--deepdrft-play-glyph) !important;
}
@@ -52,7 +52,7 @@
</MudStack>
@if (ShareContent is not null)
{
<div class="release-hero-share">
<div class="release-hero-share dd-accent-icon">
@ShareContent
</div>
}
@@ -74,7 +74,7 @@
</div>
@if (PlayContent is not null)
{
<div class="release-hero-play">
<div class="release-hero-play dd-accent-icon">
@PlayContent
</div>
}
@@ -151,14 +151,13 @@
flex: 0 0 auto;
}
/* The play affordance and share button sit over a dark image force their icon glyphs to the
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
::deep .release-hero-play .mud-icon-button,
::deep .release-hero-play .mud-progress-circular,
::deep .release-hero-share .mud-icon-button {
color: var(--deepdrft-white);
}
/* The play/share glyphs are coloured by the shared .dd-accent-icon treatment (green-accent in
both themes) applied on .release-hero-play / .release-hero-share in ReleaseHeroOverlay.razor
see deepdrft-styles.css. No co-located colour rule here: the former white override was removed
because its glyph clauses (.mud-icon-button .mud-icon-root) could not reach the
.mud-secondary-text !important glyph at wrapper specificity, and its spinner clause
(.mud-progress-circular) was live but is now correctly covered by .dd-accent-icon
making the spinner green-accent (was white) in light mode, the one intentional light delta. */
@media (max-width: 599.98px) {
.release-hero {
@@ -29,15 +29,17 @@
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
host only toggles open/closed and centers the panel — it stays purely presentational. *@
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
<div class="dd-accent-icon">
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
</div>
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
@@ -34,147 +34,164 @@
@if (Visible)
{
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudGrid>
@* ── Row 1 — MODE (always visible). ── *@
<MudItem xs="3" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">MODE:</span>
</MudStack>
</MudItem>
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled"/>
</div>
</MudTooltip>
<MudItem xs="9">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled"/>
</div>
</MudTooltip>
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
present, so visible only when BOTH are on (§3 truth table). *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
@* </div> *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Compress"
Size="Size.Small"
Color="Color.Primary"
Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
far-right of row 1 and never reflows when collisions hides (§3). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled"/>
</div>
</MudTooltip>
</MudStack>
@* ── Row 2 — WAVE section (only when waveform on). Both controls are RadialKnobs (scroll reverted
from MudSlider per Phase 15 polish); width pinned far-right via wvc-row-wave space-between. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section wvc-row-wave">
<span class="wvc-section-label">WAVE:</span>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How fast the sound rolls by.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled"/>
</div>
</MudTooltip>
</MudStack>
</div>
}
@* ── Row 3 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
</MudItem>
@* ── Row 2 — WAVE section (only when waveform on). ── *@
@if (ControlState.WaveformEnabled)
{
<MudItem xs="3" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">WAVE:</span>
</MudStack>
</MudItem>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudItem xs="9">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How fast the sound rolls by.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
</MudStack>
</MudItem>
}
@if (ControlState.LavaEnabled)
{
<MudItem xs="3" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">LAVA:</span>
</MudStack>
</MudItem>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudItem xs="9" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</MudStack>
</div>
}
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
</MudStack>
</MudItem>
}
</MudGrid>
}
</div>
@@ -38,3 +38,8 @@
color: var(--mud-palette-primary);
opacity: 0.78;
}
.wvc-row {
display: flex;
width: 100%;
}
@@ -3,19 +3,74 @@ namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
///
/// <para>
/// The two snippets diverge in height by design (Phase 17 §4.1, OQ6): a single-track embed has no
/// queue, so <see cref="ForTrack"/> stays at the compact player height; a release embed renders the
/// always-shown queue panel below the controls, so <see cref="ForRelease"/> is taller to show it
/// without clipping. Other iframe chrome (width, border radius, autoplay permission) is identical and
/// defined once in <see cref="Frame"/>.
/// </para>
///
/// <para>
/// OQ1 Option A — collapse/expand resize handshake. The release snippet carries a tiny host-side
/// listener: the embedded player posts its desired height when the viewer collapses/expands the
/// queue panel, and the listener sizes this specific iframe to match. It is scoped to the snippet's
/// own iframe (matched by id) and ignores any message whose shape does not match, so it cannot be
/// driven by foreign frames. It degrades to Option B's behaviour if the host strips the script: the
/// panel still renders and toggles inside the iframe at its default (expanded) height — only the
/// outer resize is lost. The track snippet needs no script (no panel, no toggle).
/// </para>
///
/// <para>
/// Multi-embed isolation: each <see cref="ForRelease"/> call mints a fresh random token (8 hex
/// chars). The token is used as the iframe id (<c>deepdrft-embed-{token}</c>) and threaded into
/// the iframe src as <c>&amp;EmbedId={token}</c> so the iframe can learn its own id. The host-side
/// resize script matches incoming messages on <c>embedId</c> and resizes only the iframe whose id
/// matches the token — two releases on one host page resize independently without cross-talk. Two
/// calls for the same release still get distinct tokens, ensuring uniqueness even when the same
/// release is pasted twice. Older snippets that lack <c>embedId</c> in their postMessage payload are
/// silently ignored by the script (backward-compatible degradation).
/// </para>
///
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
/// </summary>
public static class EmbedSnippetBuilder
{
// Compact single-track height (the historical embed height — must not change: UC6/AC6).
private const int TrackHeight = 196;
// Release height: the compact player plus the queue panel (fixed, internally scrollable past N
// rows per OQ6). The panel collapses to the track height via the resize handshake below.
private const int ReleaseHeight = 384;
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
public static string ForTrack(string baseUri, string trackEntryKey)
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}", TrackHeight);
public static string ForRelease(string baseUri, string releaseEntryKey)
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
{
// Mint a fresh random token per call so two embeds on the same host page never share an id,
// even when they point at the same release.
var token = Guid.NewGuid().ToString("N")[..8];
var iframeId = $"deepdrft-embed-{token}";
var src = $"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}&EmbedId={token}";
return Frame(src, ReleaseHeight, iframeId, ResizeScript(iframeId, token));
}
private static string Frame(string src)
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
=> $"""<iframe id="{iframeId}" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{trailingScript}""";
// Host-side listener: resize the matching iframe when the embedded player posts its panel height.
// The embedId field in the payload is matched against the snippet's own token so only this
// iframe is driven — foreign frames or other release embeds on the same page cannot interfere.
// The height is clamped to a sane floor so a bad payload can't collapse the player away.
// Messages without embedId (older snippets) are silently ignored.
private static string ResizeScript(string iframeId, string token) =>
"<script>(function(){var id=\"" + iframeId + "\",tok=\"" + token + "\";" +
"window.addEventListener(\"message\",function(e){var d=e.data;" +
"if(!d||d.type!==\"deepdrft-embed-resize\"||d.embedId!==tok)return;" +
"var f=document.getElementById(id);var h=Number(d.height);" +
"if(f&&h>=150)f.style.height=h+\"px\";});})();</script>";
}
@@ -4,8 +4,34 @@
<ul class="deepdrft-footer-links">
<li><a href="/about">About</a></li>
<li><a href="#">Contact</a></li>
<li><button class="deepdrft-footer-privacy-btn" @onclick="@OpenPrivacy" type="button" aria-haspopup="dialog">Privacy</button></li>
</ul>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
</div>
<p class="deepdrft-footer-privacy">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag&#8217;s gone.</p>
</footer>
</footer>
<MudOverlay Visible="@_privacyOpen"
DarkBackground="true"
Modal="true"
OnClick="@ClosePrivacy"
Class="deepdrft-privacy-overlay">
<div class="deepdrft-privacy-modal" @onclick:stopPropagation="true">
<div class="deepdrft-privacy-modal-header">
<span class="deepdrft-privacy-modal-title">Privacy</span>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Size="Size.Small"
Color="Color.Default"
OnClick="@ClosePrivacy"
aria-label="Close privacy note"
Class="deepdrft-privacy-modal-close" />
</div>
<p class="deepdrft-privacy-modal-body">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else.</p>
</div>
</MudOverlay>
@code {
private bool _privacyOpen;
private void OpenPrivacy() => _privacyOpen = true;
private void ClosePrivacy() => _privacyOpen = false;
}
@@ -3,7 +3,7 @@
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
position: relative;
z-index: 1;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
border-top: 1px solid var(--deepdrft-border);
padding: 3rem;
display: flex;
@@ -22,7 +22,7 @@
font-family: var(--deepdrft-font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
}
.deepdrft-footer-logo span {
@@ -38,33 +38,37 @@
padding: 0;
}
.deepdrft-footer-links a {
.deepdrft-footer-links a,
.deepdrft-footer-links button {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
text-decoration: none;
transition: color 0.2s;
}
.deepdrft-footer-links a:hover { color: var(--deepdrft-navy); }
.deepdrft-footer-links a:hover,
.deepdrft-footer-links button:hover { color: var(--deepdrft-page-text); }
.deepdrft-footer-copy {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
}
.deepdrft-footer-privacy {
font-family: var(--deepdrft-font-mono);
font-size: 0.55rem;
letter-spacing: 0.08em;
color: var(--deepdrft-muted);
opacity: 0.7;
/* PRIVACY trigger reset button chrome so it reads as a link, not a button element.
Typography/colour shared with footer <a> links via the grouped selector above. */
.deepdrft-footer-privacy-btn {
background: none;
border: none;
padding: 0;
margin: 0;
line-height: 1.6;
cursor: pointer;
line-height: inherit;
font-family: inherit;
}
@media (max-width: 440px) {
@@ -3,7 +3,7 @@
@using DeepDrftPublic.Client.Services
@* Desktop Menu *@
<div class="d-none d-sm-flex">
<div class="d-none d-md-flex">
<nav class="@NavClass">
<MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
@@ -42,14 +42,29 @@
<div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;"/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
</nav>
</div>
@* Mobile Menu *@
<div class="d-flex d-sm-none">
<div class="d-flex d-md-none">
<nav class="@NavClass">
<a class="dd-nav-brand" href="/">Deep DRFT</a>
<MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "/>
<span class="mx-2">Deep DRFT</span>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24 "/>
</a>
</MudStack>
<div class="dd-nav-actions">
<button type="button"
@@ -59,6 +74,8 @@
@onclick="ToggleMobileMenu">
<span></span><span></span><span></span>
</button>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
@if (_mobileMenuOpen)
@@ -117,6 +134,12 @@
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
private string DarkLightModeButtonIcon => IsDarkMode switch
{
true => DDIcons.GasLampLit,
false => DDIcons.GasLamp,
};
private async Task DarkModeToggle()
{
IsDarkMode = !IsDarkMode;
@@ -16,7 +16,12 @@
justify-content: space-between;
gap: 2rem;
padding: 1.5rem 3rem;
/* Height is pinned to the shared --deepdrft-nav-height token so the main-content
clearance (.dd-main-content) always matches the bar exactly. Contents stay
vertically centred via align-items; horizontal padding only here. */
height: var(--deepdrft-nav-height);
box-sizing: border-box;
padding: 0 3rem;
border-bottom: 1px solid var(--deepdrft-border);
box-shadow: none;
@@ -50,6 +55,10 @@
color: var(--deepdrft-white);
}
.dd-nav-dark .dd-nav-brand > ::deep img {
filter: invert(1);
}
/* Centred link list */
.dd-nav-links {
display: flex;
@@ -222,6 +231,6 @@
/* Mobile padding — give the nav room to breathe without crowding */
@media (max-width: 599px) {
.dd-nav {
padding: 1rem 1.25rem;
padding: 0 1.25rem;
}
}
+32 -1
View File
@@ -6,6 +6,7 @@
@using Microsoft.AspNetCore.Components
@inherits LayoutComponentBase
@implements IDisposable
@implements IAsyncDisposable
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
<MudPopoverProvider />
@@ -15,7 +16,7 @@
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
<AudioPlayerProvider>
<DeepDrftMenu Elevation="4" @bind-IsDarkMode="_isDarkMode" />
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
<MudMainContent Class="flex-grow-1 pb-8 dd-main-content">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
</MudContainer>
@@ -42,10 +43,13 @@
private string _audioPlayerClass = "minimized";
private const string DarkModeKey = "darkMode";
private bool _isDarkMode = false;
private bool? _lastAppliedDarkMode = null;
private PersistingComponentStateSubscription _persistingSubscription;
private IJSObjectReference? _themeModule;
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
protected override void OnInitialized()
{
@@ -66,6 +70,24 @@
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
}
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
// inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than from :root only.
// Popovers portal outside the ThemeWrapperClass div, so only a body-level class can reach them.
// Gated: only fires on first render or when _isDarkMode actually changes, to avoid redundant
// JS calls on unrelated re-renders (e.g. audio player minimize/expand).
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender || _isDarkMode != _lastAppliedDarkMode)
{
_lastAppliedDarkMode = _isDarkMode;
_themeModule ??= await JS.InvokeAsync<IJSObjectReference>(
"import", "./_content/DeepDrftShared.Client/js/theme/theme.js");
await _themeModule.InvokeVoidAsync("setBodyThemeClass", _isDarkMode);
}
}
// Theme wrapper class for CSS targeting
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
@@ -80,6 +102,15 @@
_persistingSubscription.Dispose();
}
public async ValueTask DisposeAsync()
{
if (_themeModule != null)
{
try { await _themeModule.DisposeAsync(); }
catch (JSDisconnectedException) { /* circuit torn down */ }
}
}
private void ToggleAudioPlayerMinimized(bool isMinimized)
{
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
+2 -2
View File
@@ -23,8 +23,8 @@
@* ── HERO — the page opener. Reuses the .hero-* type scale with About's own words.
NOT DeepDrftHero (that hard-codes the Deep/DRFT masthead + streaming CTA). ── *@
<section class="hero">
<MudGrid Spacing="0" Style="height: 100%;">
<section class="hero pb-20">
<MudGrid Spacing="0">
<MudItem xs="12" md="6">
<div class="hero-left">
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
+37 -29
View File
@@ -25,7 +25,6 @@
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
.hero {
min-height: 100vh;
overflow: hidden;
}
@@ -35,7 +34,7 @@
justify-content: center;
padding: 6rem 3rem;
position: relative;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
height: 100%;
}
@@ -43,7 +42,7 @@
display: flex;
flex-direction: column;
justify-content: center;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
height: 100%;
}
@@ -56,7 +55,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.8rem;
display: flex;
@@ -70,7 +69,7 @@
display: block;
width: 2.5rem;
height: 1px;
background: var(--deepdrft-green-accent);
background: var(--deepdrft-green);
}
.hero-title {
@@ -79,21 +78,21 @@
font-weight: 300;
line-height: 0.92;
letter-spacing: -0.02em;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 0.5rem;
animation-delay: 0.22s;
}
.hero-title em {
font-style: italic;
color: var(--deepdrft-green);
color: var(--deepdrft-green-accent);
}
.hero-desc {
font-family: var(--deepdrft-font-body);
font-size: 0.92rem;
line-height: 1.75;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.7;
max-width: 36ch;
margin-bottom: 3rem;
@@ -109,7 +108,7 @@
.movement {
display: grid;
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
align-items: start;
}
@@ -141,14 +140,14 @@
font-weight: 300;
line-height: 1;
letter-spacing: -0.04em;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.14;
padding-left: 1.4rem;
transition: color 0.5s ease, opacity 0.5s ease;
}
.movement.is-active .rail-numeral {
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
opacity: 0.95;
}
@@ -163,7 +162,7 @@
font-size: 0.58rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
writing-mode: vertical-rl;
transform: rotate(180deg);
transform-origin: center;
@@ -195,7 +194,7 @@
.wave-stroke path {
fill: none;
stroke: var(--deepdrft-green-accent);
stroke: var(--deepdrft-green);
stroke-width: 1.4;
opacity: 0.7;
vector-effect: non-scaling-stroke;
@@ -207,7 +206,7 @@
font-size: 0.62rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
white-space: nowrap;
}
@@ -221,7 +220,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.4rem;
}
@@ -231,20 +230,20 @@
font-size: clamp(2.6rem, 5vw, 4.2rem);
font-weight: 300;
line-height: 1.02;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 2rem;
}
.movement-title em {
font-style: italic;
color: var(--deepdrft-green);
color: var(--deepdrft-green-accent);
}
.movement-prose {
font-family: var(--deepdrft-font-body);
font-size: 0.95rem;
line-height: 1.85;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.72;
max-width: 56ch;
}
@@ -279,14 +278,15 @@
}
/* Graceful-degrade slot shown until a portrait file lands. A flat tonal panel in
the navy family, matching the circular portrait frame. */
the navy family, matching the circular portrait frame. Mixes a touch of navy into
--deepdrft-page-surface so the gradient inverts with the section in dark mode. */
.bio-portrait-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
background:
linear-gradient(160deg,
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white)) 0%,
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-white)) 100%);
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-page-surface)) 0%,
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-page-surface)) 100%);
}
/* The marginalia caption — mono, sits directly under the framed portrait. */
@@ -295,7 +295,7 @@
font-size: 0.56rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
margin-top: 0.9rem;
padding-left: 0.1rem;
}
@@ -309,7 +309,7 @@
font-size: 2rem;
font-weight: 300;
line-height: 1.1;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 1rem;
}
@@ -317,7 +317,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.85rem;
line-height: 1.8;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.7;
}
@@ -339,7 +339,7 @@
font-size: 0.56rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
margin-top: 0.9rem;
}
@@ -354,7 +354,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.2rem;
}
@@ -467,7 +467,7 @@
font-family: var(--deepdrft-font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
min-width: 7rem;
}
@@ -477,7 +477,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.85rem;
line-height: 1.6;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.6;
}
@@ -502,7 +502,7 @@
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
font-weight: 300;
line-height: 1.15;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
}
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
@@ -606,6 +606,14 @@
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
/* ── DARK-MODE OVERRIDES ── */
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
:global(.deepdrft-theme-dark) .hero-title em,
:global(.deepdrft-theme-dark) .movement-title em {
color: var(--deepdrft-green-accent);
}
/* RESPONSIVE COLLAPSE
Below 960px the rail collapses: the spine + vertical numeral can't survive a
@@ -9,7 +9,7 @@
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 36px 0 20px 0;
padding: 12px 0 20px 0;
}
.archive-controls-search {
+2 -2
View File
@@ -83,7 +83,7 @@ else
</div>
}
<div class="cut-detail-actions">
<div class="cut-detail-actions dd-accent-icon dd-accent-fill">
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
closed P11 W1). Disabled until at least one streamable track is resolved. *@
<MudButton Variant="Variant.Filled"
@@ -133,7 +133,7 @@ else
{
var track = ViewModel.Tracks[i];
var index = i;
<div class="cut-detail-track-row">
<div class="cut-detail-track-row dd-accent-icon">
<span class="cut-detail-track-number">@track.TrackNumber</span>
<div class="cut-detail-track-play">
<PlayStateIcon Track="@track"
+29 -19
View File
@@ -15,7 +15,7 @@
justify-content: center;
padding: 6rem 3rem;
position: relative;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
height: 100%;
}
@@ -30,7 +30,7 @@
align-items: center;
gap: 2rem;
padding: 2rem 3rem;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
}
.divider-line {
@@ -43,7 +43,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.25em;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
text-transform: uppercase;
white-space: nowrap;
}
@@ -51,7 +51,7 @@
/* ── SECTION (sound) ── */
.section {
padding: 7rem 3rem;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
}
@media (min-width: 960px) {
@@ -64,7 +64,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.2rem;
}
@@ -74,12 +74,12 @@
font-size: clamp(2.8rem, 5vw, 4.5rem);
font-weight: 300;
line-height: 1;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
}
.section-title em {
font-style: italic;
color: var(--deepdrft-green);
color: var(--deepdrft-green-accent);
}
/* The body column is already full height; make it a flex container that
@@ -106,7 +106,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.9rem;
line-height: 1.8;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.65;
max-width: 52ch;
}
@@ -130,7 +130,7 @@
}
.medium-card {
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
border: 1px solid var(--deepdrft-border);
cursor: pointer;
overflow: hidden;
@@ -189,7 +189,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.2em;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
text-transform: uppercase;
margin-bottom: 0.6rem;
}
@@ -198,7 +198,7 @@
font-family: var(--deepdrft-font-display);
font-size: 1.6rem;
font-weight: 400;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 0.75rem;
line-height: 1.1;
}
@@ -207,7 +207,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.82rem;
line-height: 1.65;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.6;
}
@@ -333,7 +333,7 @@
display: flex;
flex-direction: column;
justify-content: center;
background: var(--deepdrft-white);
background: var(--deepdrft-page-surface);
height: 100%;
}
@@ -387,7 +387,7 @@
font-family: var(--deepdrft-font-display);
font-size: clamp(2rem, 3.5vw, 3rem);
font-weight: 300;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
line-height: 1.05;
margin-bottom: 2rem;
}
@@ -417,7 +417,7 @@
.connect-option:hover {
border-color: var(--deepdrft-green-accent);
background: #f3f6f4;
background: color-mix(in srgb, var(--deepdrft-page-surface), var(--deepdrft-green-accent) 8%);
}
.option-icon {
@@ -426,14 +426,16 @@
display: flex;
align-items: center;
justify-content: center;
background: var(--deepdrft-navy);
/* Inversion pair with the glyph below: a contrast chip against the page surface
(navy chip / white glyph in light; white chip / navy glyph on the dark ground). */
background: var(--deepdrft-page-text);
flex-shrink: 0;
}
.option-icon svg {
width: 0.9rem;
height: 0.9rem;
stroke: var(--deepdrft-white);
stroke: var(--deepdrft-page-surface);
fill: none;
stroke-width: 1.5;
}
@@ -442,14 +444,14 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
letter-spacing: 0.15em;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
text-transform: uppercase;
}
.option-text-sub {
font-family: var(--deepdrft-font-body);
font-size: 0.75rem;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
margin-top: 0.1rem;
}
@@ -558,6 +560,14 @@
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
/* ── DARK-MODE OVERRIDES ── */
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
:global(.deepdrft-theme-dark) .section-title em,
:global(.deepdrft-theme-dark) .connect-title em {
color: var(--deepdrft-green-accent);
}
@media (max-width: 599px) {
.cta-banner {
flex-direction: column;
@@ -0,0 +1,55 @@
// Embed (iframe) → host resize handshake (Phase 17 wave 17.3, OQ1 Option A).
//
// The Fixed-mode player renders an always-shown queue panel below the controls. A collapse/expand
// toggle lets the embedder's viewer reclaim the panel's vertical space — but collapsing inside the
// iframe only reclaims space if the *outer* iframe element also shrinks. The iframe cannot resize
// itself, so it posts its desired pixel height to the host page; the embed snippet (minted by
// EmbedSnippetBuilder.ForRelease) carries a tiny listener that sets iframe.style.height.
//
// Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the
// panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing
// when not framed (window === parent), so the docked player is unaffected.
//
// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes
// it as ?EmbedId=<token> in the iframe src. We read it here from window.location.search and include
// it in the postMessage payload as `embedId`. The host-side resize script matches on this token so
// only the correct iframe is resized when multiple embeds share a host page. If EmbedId is absent
// (older already-pasted snippets), embedId is omitted from the payload — those snippets' scripts
// ignore messages without a matching embedId, so there is no cross-talk either way.
const MESSAGE_TYPE = "deepdrft-embed-resize";
/** Read the EmbedId query param from the iframe's own URL, if present. */
function readEmbedId(): string | null {
try {
return new URLSearchParams(window.location.search).get("EmbedId");
} catch {
return null;
}
}
// Resolved once at module load — the URL does not change while the iframe is alive.
const embedId: string | null = readEmbedId();
/**
* Measure the live rendered height of the player element and post it to the host page so it can size
* the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable.
*
* targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the
* payload carries no secrets just a height the host is free to ignore.
*
* The payload includes `embedId` when the iframe src carried an EmbedId query param. The host-side
* resize script matches on this field to isolate multiple embeds on the same page.
*/
export function postHeight(element: HTMLElement): void {
if (window.parent === window) return; // Not framed — nothing to resize.
if (!element) return;
// ceil + a hairline guard against sub-pixel rounding that would otherwise clip the bottom edge.
const height = Math.ceil(element.getBoundingClientRect().height) + 2;
if (!Number.isFinite(height) || height <= 0) return;
const payload: Record<string, unknown> = { type: MESSAGE_TYPE, height };
if (embedId !== null) payload.embedId = embedId;
window.parent.postMessage(payload, "*");
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 525 KiB

+224 -21
View File
@@ -18,6 +18,15 @@ html, body {
color: var(--mud-palette-text-primary);
}
/* Main-content clearance for the fixed frosted-glass nav (.dd-nav). The nav is
position:fixed (so content scrolls under its backdrop blur) and thus out of flow;
in MainLayout's flex column the content would otherwise start at the top and slide
under the bar. Pad the top by the shared --deepdrft-nav-height token so the clearance
tracks the bar exactly across breakpoints. Replaces the old hardcoded MudBlazor pt-16. */
.dd-main-content {
padding-top: var(--deepdrft-nav-height, 88px);
}
/* Ensure the theme wrapper fills the full viewport so no background gap shows. */
.deepdrft-theme-dark,
.deepdrft-theme-light {
@@ -358,6 +367,22 @@ h2, h3, h4, h5, h6,
font-family: var(--deepdrft-font-mono) !important;
}
/* Default MudBlazor popover surface (Phase 18, T4 symptom #1). Selects, menus, and the
share-popover body render inside .mud-popover. (Tooltips are NOT covered here MudBlazor
tooltips paint from --mud-palette-text, not the popover surface.) Their visible surface is the
inner .mud-paper, which paints background-color: var(--mud-palette-surface). Inspection settled
the root cause: the "too dark" is NOT --deepdrft-panel-ground leakage (the bespoke dark-glass
panels are MudOverlay .mud-overlay-content surfaces and never match .mud-popover) it is simply
that the popover surface tracks --mud-palette-surface with no desaturated-navy treatment. So
re-point --mud-palette-surface to the theme-aware --deepdrft-popover-surface *within the popover
scope only*: a soft desaturated-navy wash in light, the existing panel-ground charcoal in dark.
Scoping the variable (not a flat background) means any inner .mud-paper, .mud-list, or menu picks
it up for free, while the global surface used elsewhere on the page is unaffected. */
.mud-popover {
--mud-palette-surface: var(--deepdrft-popover-surface);
background-color: var(--deepdrft-popover-surface);
}
.deepdrft-share-popover-body {
padding: 0.75rem 1rem;
min-width: 280px;
@@ -399,11 +424,11 @@ h2, h3, h4, h5, h6,
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
============================================================================= */
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
/* Greyed panel ground desaturated charcoal so the blue slider reads against it (defect #1).
Token is tunable in deepdrft-tokens.css without touching this rule. */
background: var(--deepdrft-panel-ground);
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
border: 1px solid var(--deepdrft-border-light);
/* Theme-aware glass ground dark charcoal in dark theme, light translucent glass in light
(so the deck reads against the light page). Tunable in deepdrft-tokens.css. */
background: var(--deepdrft-panel-surface);
/* Square corners + thin theme-aware border — NowPlayingCard chrome (§5). */
border: 1px solid var(--deepdrft-panel-border);
border-radius: 0;
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
backdrop-filter: blur(8px);
@@ -420,7 +445,7 @@ h2, h3, h4, h5, h6,
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
--mud-palette-text-primary: var(--deepdrft-panel-text); /* knob value label — flips dark on light glass */
}
/* Row layout (§3). Each row is a horizontal band. Row 1 (MODE) and row 3 (WAVE) use
@@ -461,13 +486,13 @@ h2, h3, h4, h5, h6,
}
/* Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
tracked), recoloured LIGHT labels are static, so light by the colour principle (§5, §10.3). */
tracked), coloured via --deepdrft-panel-text theme-aware (navy in light, off-white in dark). */
.waveform-visualizer-control-panel .wvc-section-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--deepdrft-white);
color: var(--deepdrft-panel-text);
align-self: center;
flex: 0 0 auto;
opacity: 0.85;
@@ -495,10 +520,11 @@ h2, h3, h4, h5, h6,
opacity: 0.38;
}
/* Caption icons render LIGHT (§5/§9: static/decorative = light). !important beats the scoped
.mix-visualizer-control ::deep .mix-visualizer-control-icon rule (which sets green for the legacy
inline mount) when the icon also carries mix-visualizer-control-icon. Lamp toggles are MudIconButton
not MudIcon so they are unaffected they stay green (interactive, Color.Primary). (defect #3) */
/* Caption icons inherit the portaled panel's body text theme-aware (dark text on light glass,
off-white on dark glass). !important beats the scoped .mix-visualizer-control ::deep
.mix-visualizer-control-icon rule (which sets green for the legacy inline mount) when the icon also
carries mix-visualizer-control-icon. Lamp toggles are MudIconButton not MudIcon so they are
unaffected they stay green (interactive, Color.Primary). (defect #3) */
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
opacity: 0.85;
translate: 0 -1rem;
@@ -597,6 +623,28 @@ body:has(.waveform-visualizer-control-overlay) {
}
}
/* Dark-mode button overrides (Phase 18, Wave 3).
In dark, --deepdrft-navy fill/text blends into the #0D1B2A page ground.
Primary: green-accent fill + navy text reads as a clear CTA (matches play-chip language).
Ghost: white text + light border stands off the dark ground. */
.deepdrft-theme-dark .btn-primary {
background: var(--deepdrft-green-accent);
color: var(--deepdrft-navy);
}
.deepdrft-theme-dark .btn-primary:hover {
background: var(--deepdrft-green-interactive);
}
.deepdrft-theme-dark .btn-ghost {
color: var(--deepdrft-page-text);
border-color: var(--deepdrft-border-light);
}
.deepdrft-theme-dark .btn-ghost:hover {
border-color: var(--deepdrft-page-text);
}
/* =============================================================================
CUT ALBUM DETAIL (/cuts/{id})
Header splits left-meta / right-cover; the cover carries an explicit theme
@@ -703,6 +751,69 @@ body:has(.waveform-visualizer-control-overlay) {
}
}
/* =============================================================================
INTERACTIVE-ACCENT ICON TREATMENT (.dd-accent-icon / .dd-accent-fill)
----------------------------------------------------------------------------
The single, reusable green-accent treatment for interactive icon affordances
replaces the per-site dark-mode overrides that previously had to fight the palette.
WHY a class and not a palette colour: no MudBlazor Color enum is green in BOTH
themes (Dark.Secondary is off-white, Dark.Primary is green; Light.Secondary is
green, Light.Primary is navy), so every "green in both" affordance had to be
patched per-site. --deepdrft-green-accent (#3D7A68) is the brand constant the
SAME value in both palettes so a non-theme-scoped rule is correct: light already
renders these glyphs green-accent (via Color.Secondary Light.Secondary), so this
keeps light pixel-identical while fixing dark.
WHY it reaches the glyph: MudBlazor colours a Color.Secondary icon by stamping
.mud-secondary-text on the inner .mud-icon-root <svg>, and that rule is `!important`
(color: var(--mud-palette-secondary) !important). Targeting only the .mud-icon-button
wrapper therefore never wins the svg keeps its own !important colour. The documented
override bug. The glyph clause .dd-accent-icon .mud-icon-button .mud-icon-root is
specificity (0,3,0) + !important, which beats MudBlazor's standalone .mud-secondary-text
(0,1,0) + !important on specificity alone source order is not load-bearing for the
glyph clause. The .mud-icon-button selector carries the
Color.Inherit affordances (lava-lamp glyph inherits the wrapper colour, no
.mud-secondary-text to fight); the spinner covers the PlayStateIcon loading state.
Apply .dd-accent-icon to a CONTAINER of the affordance(s); add .dd-accent-fill
alongside it when the container ALSO holds a filled MudButton whose Color.Secondary
fill must go green-accent in dark (a filled button is a background fill, not a glyph
light already renders green-accent fill + white text, so .dd-accent-fill is DARK-ONLY
to keep light pixel-identical). The Session/Mix hero Share/Play glyphs use this class
too (they were already green-accent in light via Color.Secondary, so folding them in
keeps light pixel-identical and fixes dark the over-image glyphs are not actually
theme-divergent). The one genuinely theme-divergent affordance (gas-lamp = inherited
nav text in light) does NOT use this class it keeps a dark-only rule below.
The glyph rule targets glyphs inside an ICON button (.mud-icon-button .mud-icon-root)
only the filled Play button is a .mud-button-filled (not .mud-icon-button), so its
StartIcon is naturally excluded and keeps its own contrast colour (white in light,
navy in dark). The bare .mud-icon-button selector carries the Color.Inherit case
(lava-lamp glyph inherits the wrapper colour); the spinner covers the loading state. */
.dd-accent-icon .mud-icon-button .mud-icon-root,
.dd-accent-icon .mud-icon-button,
.dd-accent-icon .mud-progress-circular {
color: var(--deepdrft-green-accent) !important;
}
/* Filled-button variant (DARK-ONLY): green-accent fill + navy glyph/label, matching the
play-chip language. In dark, Color.Secondary fill resolves to off-white (unreadable);
here it becomes a clear green CTA. Light is untouched (already green fill + white text). */
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled {
background-color: var(--deepdrft-green-accent);
color: var(--deepdrft-navy);
}
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled .mud-icon-root {
color: var(--deepdrft-navy) !important;
}
/* Gas-lamp dark-mode toggle: the frame now carries an explicit #2A5C4F fill in its SVG
(DDIcons.GasLampLit), so no CSS colour override is needed here in dark. The nav rule
that previously set green-accent on the MudIconButton has been removed it was the
only .mud-icon-button in .dd-nav-actions and is now dead. */
/* =============================================================================
RELEASE DESCRIPTION BLURB
Shared block rendered just below the hero/header on every release detail page
@@ -791,8 +902,8 @@ body:has(.deepdrft-queue-overlay) {
width: min(90vw, 520px);
height: min(90vw, 520px);
max-height: 90vh;
background: var(--deepdrft-panel-ground);
border: 1px solid var(--deepdrft-border-light);
background: var(--deepdrft-panel-surface);
border: 1px solid var(--deepdrft-panel-border);
border-radius: 0;
backdrop-filter: blur(8px);
overflow: hidden;
@@ -803,16 +914,16 @@ body:has(.deepdrft-queue-overlay) {
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--deepdrft-border-light);
border-bottom: 1px solid var(--deepdrft-panel-border);
}
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, theme-aware (static). */
.deepdrft-queue-modal-title {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
color: var(--deepdrft-panel-text);
opacity: 0.85;
}
@@ -840,12 +951,12 @@ body:has(.deepdrft-queue-overlay) {
gap: 0.6rem;
padding: 0.45rem 0.5rem;
border-radius: 4px;
color: var(--deepdrft-white);
color: var(--deepdrft-panel-text);
transition: background 0.15s ease;
}
.deepdrft-queue-row:hover {
background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
background: var(--deepdrft-panel-row-hover);
}
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
@@ -863,7 +974,7 @@ body:has(.deepdrft-queue-overlay) {
.deepdrft-queue-position {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
opacity: 0.6;
color: var(--deepdrft-panel-text-muted);
min-width: 1.4rem;
text-align: right;
flex: 0 0 auto;
@@ -888,7 +999,7 @@ body:has(.deepdrft-queue-overlay) {
.deepdrft-queue-artist {
font-size: 0.74rem;
opacity: 0.6;
color: var(--deepdrft-panel-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -905,3 +1016,95 @@ body:has(.deepdrft-queue-overlay) {
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
border-radius: 6px;
}
/* Fixed (embed) inline queue panel (Phase 17 §4, OQ6).
Rendered below the player controls inside the embed surface. A fixed sensible height with internal
scroll past N rows (NOT grow-to-cap): ~4.5 rows are visible, the rest scroll. A top hairline
separates it from the controls. The list rows reuse the shared .deepdrft-queue-* styles above. */
.deepdrft-queue-embed-panel {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--deepdrft-border-light);
max-height: 184px;
overflow-y: auto;
}
/* =============================================================================
PRIVACY OVERLAY
Screen-centered modal following the same MudOverlay idiom as the visualizer
controls and queue overlays. MudOverlay portals to body CSS isolation cannot
reach portaled content, so chrome lives here in the global sheet.
============================================================================= */
/* Raise above the sticky header (100), player dock (1200), and minimized FAB (1300). */
.deepdrft-privacy-overlay {
z-index: 1400 !important;
}
/* Mild tint: doubled selector (0,2,0) outranks MudBlazor's .mud-overlay-dark (0,1,0). */
.deepdrft-privacy-overlay .mud-overlay-scrim.mud-overlay-dark {
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
}
.deepdrft-privacy-overlay .mud-overlay-content {
max-height: 90vh;
overflow: visible;
}
/* Lock body scroll while the overlay is open. */
body:has(.deepdrft-privacy-overlay) {
overflow: hidden;
}
/* Panel: compact width, navy-panel ground, thin light border — matches queue/visualizer chrome. */
.deepdrft-privacy-modal {
display: flex;
flex-direction: column;
width: min(90vw, 480px);
background: var(--deepdrft-panel-surface);
border: 1px solid var(--deepdrft-panel-border);
border-radius: 0;
backdrop-filter: blur(8px);
overflow: hidden;
}
.deepdrft-privacy-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 0.85rem 0.85rem 1rem;
border-bottom: 1px solid var(--deepdrft-panel-border);
}
/* Mono uppercase eyebrow — matches queue modal title. */
.deepdrft-privacy-modal-title {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-panel-text);
opacity: 0.85;
}
/* Tuck the close icon flush with the panel edge; keep it subtle. */
.deepdrft-privacy-modal-close {
opacity: 0.6;
color: var(--deepdrft-panel-text) !important;
}
.deepdrft-privacy-modal-close:hover {
opacity: 1;
}
/* Privacy copy: same mono treatment as the former inline paragraph; theme-aware text colour
so it stays legible on both the dark-glass (dark) and light-glass (light) panel surfaces. */
.deepdrft-privacy-modal-body {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
line-height: 1.7;
color: var(--deepdrft-panel-text);
opacity: 0.8;
margin: 0;
padding: 1rem 1rem 1.25rem;
}
+7 -2
View File
@@ -12,11 +12,16 @@ public static class DDIcons
""";
/// <summary>
/// Charleston gas lamp with lit flame - for dark mode
/// Charleston gas lamp with lit flame - for dark mode.
/// Frame/body path uses an explicit darker-green fill (#2A5C4F — palette PrimaryDarken /
/// --deepdrft-green-light) instead of currentColor so it is deterministic in the nav
/// regardless of inherited colour. The flame ellipses keep their literal orange/yellow/cream
/// fills. Only rendered in dark mode (DarkLightModeButtonIcon in DeepDrftMenu.razor).
/// If the palette's PrimaryDarken changes, update #2A5C4F to match.
/// </summary>
public const string GasLampLit = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
<path fill="#2A5C4F" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
<ellipse cx="12" cy="13" rx="2.5" ry="3.5" fill="#FF9800"/>
<ellipse cx="12" cy="12.5" rx="1.5" ry="2.5" fill="#FFCA28"/>
<ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/>
@@ -114,7 +114,7 @@ public static class DeepDrftPalettes
Tertiary = "#1A3C34", // Deep green - tertiary accent
Background = "#0D1B2A", // Navy - the light palette's primary as the dark ground
Surface = "#162437", // Navy-mid - elevated cards/panels
AppbarBackground = "rgba(13,27,42,0.92)", // Semi-opaque navy
AppbarBackground = "rgba(17,35,56,0.92)", // Semi-opaque #112338 navy — distinct appbar bar, lighter than the #0D1B2A page ground
AppbarText = "#FAFAF8",
DrawerBackground = "#162437", // Navy-mid
DrawerText = "#FAFAF8",
@@ -0,0 +1,15 @@
/**
* theme - body-class helpers for dark-mode theme toggling.
*
* Single Responsibility: apply or remove the deepdrft-theme-dark class on
* document.body so that portaled MudBlazor elements (popovers, menus, selects)
* inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
* from :root only. Popovers portal outside the ThemeWrapperClass div, so only
* a body-level class can reach them.
*/
/** Toggle the deepdrft-theme-dark class on document.body.
* @param isDark true to add the class, false to remove it. */
export function setBodyThemeClass(isDark: boolean): void {
document.body.classList.toggle('deepdrft-theme-dark', isDark);
}
@@ -0,0 +1,15 @@
/**
* theme - body-class helpers for dark-mode theme toggling.
*
* Single Responsibility: apply or remove the deepdrft-theme-dark class on
* document.body so that portaled MudBlazor elements (popovers, menus, selects)
* inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
* from :root only. Popovers portal outside the ThemeWrapperClass div, so only
* a body-level class can reach them.
*/
/** Toggle the deepdrft-theme-dark class on document.body.
* @param isDark true to add the class, false to remove it. */
export function setBodyThemeClass(isDark) {
document.body.classList.toggle('deepdrft-theme-dark', isDark);
}
//# sourceMappingURL=/js/theme/theme.js.map
@@ -0,0 +1 @@
{"version":3,"file":"theme.js","sourceRoot":"/Interop/","sources":["theme/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;gEACgE;AAChE,MAAM,UAAU,iBAAiB,CAAC,MAAe;IAC7C,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAClE,CAAC"}
@@ -28,9 +28,24 @@
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
--deepdrft-modal-scrim-alpha: 0.15;
/* Panel ground muted, desaturated charcoal beneath the controls panel.
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker. */
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker.
Source token; consumed by the theme-aware --deepdrft-panel-surface dark value below. */
--deepdrft-panel-ground: #1a1c22;
/* Glass-panel family the bespoke overlay panels (queue / visualizer control deck / privacy).
Light values here make these panels a light translucent glass with dark text so they read
coherently against the light page; the .deepdrft-theme-dark block below reproduces today's
dark-glass charcoal exactly so dark mode is visually unchanged. Surface keeps the glassmorphic
translucency (paired with backdrop-blur in the consuming rules).
Light surface: near-page-surface white at 82% so the backdrop blur still shows through;
text/border are navy-based for legibility on the light glass. */
--deepdrft-panel-surface: rgba(250, 250, 248, 0.82);
--deepdrft-panel-text: var(--deepdrft-navy);
--deepdrft-panel-text-muted: var(--deepdrft-muted);
--deepdrft-panel-border: var(--deepdrft-border);
/* Row/hover wash on the panel surface — a navy tint on light, a white tint on dark (below). */
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-navy) 8%, transparent);
/* Wireframe font stack */
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
--deepdrft-font-mono: "Geist Mono", monospace;
@@ -68,11 +83,54 @@
--gradient-warm: var(--deepdrft-green);
--gradient-light: var(--deepdrft-green-accent);
/* Theme-aware page-surface family (Phase 18). The "neutral page surface" concept:
sections that were hardcoded to --deepdrft-white because the site was light-only.
Light values reproduce today's look exactly; the .deepdrft-theme-dark block below
inverts them onto the navy ground so neutral sections dissolve into one dark field. */
--deepdrft-page-surface: var(--deepdrft-white);
--deepdrft-page-text: var(--deepdrft-navy);
--deepdrft-page-text-muted: var(--deepdrft-muted);
/* Play-chip family (Phase 18). PlayStateIcon's chip is shared across release heroes,
Cut track rows, and the player bar. Light keeps the current soft-grey chip + glyph;
dark turns the chip moss-green with a navy glyph. The -soft variant is the player-bar
override (same green, much less opaque). */
--deepdrft-play-chip: var(--deepdrft-soft);
--deepdrft-play-glyph: var(--deepdrft-navy);
--deepdrft-play-chip-soft: var(--deepdrft-soft);
/* Popover surface (Phase 18). Default MudBlazor popovers (selects/menus/tooltips/share
body) bind this. Light uses a very subtle navy wash (4%) near the page background but
just perceptibly off-white so the popover reads as an elevated surface. Dark uses a
bluer navy (colour-mix of navy-mid + green-accent at 20%), defined once in
--deepdrft-popover-surface-dark below and referenced by both the .deepdrft-theme-dark
wrapper block and the body.deepdrft-theme-dark block so portaled popover content (which
portals to <body>, outside the wrapper div) is also reached. The bespoke glass panels
(visualizer/queue/privacy) do NOT bind this they have their own theme-aware
--deepdrft-panel-* family (dark glass in dark theme, light glass in light). */
--deepdrft-popover-surface-dark: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%);
--deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy) 4%, var(--deepdrft-white));
/* Fixed-nav height single source of truth shared by the frosted-glass nav
(DeepDrftMenu.razor.css pins .dd-nav to this) and the main-content clearance
(.dd-main-content padding-top in deepdrft-styles.css). The nav is position:fixed
so content scrolls under its backdrop blur; this keeps the clearance in lockstep
with the bar so content never overlaps. Mobile (<600px) override below. */
--deepdrft-nav-height: 88px;
/* Legacy font aliases retired in Phase 0.1 all consumers now use --deepdrft-font-*.
Palette aliases (--deepdrft-primary, --theme-*, etc.) remain; they still have
consumers and are scheduled for retirement in Phase 0.3/0.4. */
}
/* Mobile fixed-nav height matches the <600px breakpoint in DeepDrftMenu.razor.css
(tighter horizontal padding + smaller bar). Cascades to .dd-nav and .dd-main-content. */
@media (max-width: 599px) {
:root {
--deepdrft-nav-height: 72px;
}
}
/* Dark theme - wireframe palette (navy ground / green-accent / off-white).
Mirrors the light palette's vocabulary on a dark ground. Same alias structure
as :root so utility classes (.deepdrft-chip-*, .deepdrft-border-*, .deepdrft-text-*)
@@ -108,4 +166,55 @@
--gradient-accent: var(--deepdrft-green-accent);
--gradient-warm: var(--deepdrft-green);
--gradient-light: var(--deepdrft-green-light);
/* Theme-aware page-surface family (Phase 18) inverted onto the true page ground.
Binds --mud-palette-background (#0D1B2A) so neutral sections (Home hero-left,
medium grid, footer, About light sections) dissolve into the site background as
one continuous dark field rather than reading as raised panels (#112338 navy
is card-elevation, not the page ground). */
--deepdrft-page-surface: var(--mud-palette-background);
--deepdrft-page-text: var(--deepdrft-white);
/* Lift muted text toward white so eyebrows/sub-text stay legible on the dark ground. */
--deepdrft-page-text-muted: color-mix(in srgb, var(--deepdrft-muted) 70%, var(--deepdrft-white));
/* Play-chip family (Phase 18) moss-green chip, navy glyph (green-on-green on the
player bar; navy-on-green on solid chips). The -soft variant is the player-bar
override: same green, much less opaque (translucent wash over the navy dock). */
--deepdrft-play-chip: var(--deepdrft-green-accent);
--deepdrft-play-glyph: var(--deepdrft-navy);
--deepdrft-play-chip-soft: color-mix(in srgb, var(--deepdrft-green-accent) 30%, transparent);
/* Popover surface (Phase 18) within .deepdrft-theme-dark wrapper this value applies to
non-portaled elements only (drawers, inline menus). Portaled MudBlazor popovers live at
<body> level; the body.deepdrft-theme-dark block below uses the same source token. */
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
/* Glass-panel family (dark) reproduces today's dark-glass chrome EXACTLY. Surface is the
opaque charcoal ground the panels used directly before tokenisation; text is off-white;
border is the thin light-on-dark hairline (NowPlayingCard spirit); row hover is the prior
white 6% wash. Dark mode must look unchanged. */
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
--deepdrft-panel-text: var(--deepdrft-white);
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
--deepdrft-panel-border: var(--deepdrft-border-light);
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
}
/* Portal-scope dark popover surface. MudBlazor popovers (selects, menus, share body) portal
to <body>, placing them outside the .deepdrft-theme-dark wrapper div. MainLayout.razor syncs
deepdrft-theme-dark onto <body> via JS after each render, so this selector reaches portaled
content. Resolved from --deepdrft-popover-surface-dark (defined in :root above) bluer navy
(navy-mid + 20% green-accent tint) rather than the pure charcoal #162437. */
body.deepdrft-theme-dark {
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
/* The bespoke glass panels (queue / visualizer / privacy) are MudOverlay panels that portal to
<body>, outside the .deepdrft-theme-dark wrapper div same portal scope as popovers. Re-declare
the dark glass-panel family here so the panels resolve the dark (charcoal) values; without this
they would fall through to the light :root values while the page is in dark mode. */
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
--deepdrft-panel-text: var(--deepdrft-white);
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
--deepdrft-panel-border: var(--deepdrft-border-light);
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
}
+3
View File
@@ -28,6 +28,9 @@
</ItemGroup>
<ItemGroup>
<!-- Referenced for UnifiedTrackService — the dual-database upload orchestrator whose create-path
duplicate guard and within-batch attach path are exercised in UploadDuplicateDetectionTests. -->
<ProjectReference Include="..\DeepDrftAPI\DeepDrftAPI.csproj" />
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain
+112 -6
View File
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests;
@@ -5,8 +6,9 @@ namespace DeepDrftTests;
/// <summary>
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
/// across both. Pure string composition, tested directly without rendering the component.
/// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but
/// diverge in height by design (Phase 17 §4.1, OQ6): the release embed is taller to show its queue
/// panel; the track embed stays compact. Pure string composition, tested without rendering.
/// </summary>
[TestFixture]
public class EmbedSnippetBuilderTests
@@ -27,12 +29,13 @@ public class EmbedSnippetBuilderTests
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
// src contains ReleaseEntryKey; may also carry additional query params (e.g. EmbedId).
Assert.That(snippet, Does.Contain("ReleaseEntryKey=rel-xyz"));
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
}
[Test]
public void BothModes_ShareIdenticalIframeChrome()
public void BothModes_ShareIdenticalNonHeightChrome()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
@@ -43,12 +46,115 @@ public class EmbedSnippetBuilderTests
{
Assert.That(snippet, Does.StartWith("<iframe "));
Assert.That(snippet, Does.Contain(@"width=""656"""));
Assert.That(snippet, Does.Contain(@"height=""196"""));
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
Assert.That(snippet, Does.EndWith("></iframe>"));
Assert.That(snippet, Does.Contain("</iframe>"));
}
});
}
// T14 (Phase 17 §9): the release embed is taller than the track embed (it shows a queue panel),
// and the track embed's height is unchanged from its historical value (UC6/AC6).
[Test]
public void ForTrack_KeepsHistoricalCompactHeight()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
Assert.That(track, Does.Contain(@"height=""196"""));
}
[Test]
public void ForRelease_IsTallerThanForTrack_ToShowQueuePanel()
{
var trackHeight = HeightOf(EmbedSnippetBuilder.ForTrack(BaseUri, "k"));
var releaseHeight = HeightOf(EmbedSnippetBuilder.ForRelease(BaseUri, "k"));
Assert.That(releaseHeight, Is.GreaterThan(trackHeight));
}
// The release snippet carries the host-side resize listener (OQ1 Option A); the track snippet,
// having no panel to collapse, does not.
[Test]
public void ForRelease_IncludesResizeListenerScript()
{
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
Assert.Multiple(() =>
{
Assert.That(release, Does.Contain("<script>"));
Assert.That(release, Does.Contain("deepdrft-embed-resize"));
});
}
[Test]
public void ForTrack_HasNoResizeListenerScript()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
Assert.That(track, Does.Not.Contain("<script>"));
}
// --- Multi-embed isolation (Phase 17 major remediation) ---
// Two ForRelease calls must produce snippets with different iframe ids so both can coexist on one
// host page without the host-side resize script resolving only the first via getElementById.
[Test]
public void ForRelease_TwoCalls_ProduceDifferentIframeIds()
{
var a = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
var b = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); // same release, different call
var idA = IframeId(a);
var idB = IframeId(b);
Assert.That(idA, Is.Not.EqualTo(idB),
"each ForRelease call must mint a distinct iframe id to prevent multi-embed cross-talk");
}
// The iframe id and the token embedded in the host-side resize script must be consistent within
// a single snippet — the script assigns the id string to a JS variable and calls getElementById
// with it, so the id literal must appear in the script's var initializer.
[Test]
public void ForRelease_IframeIdAndScriptToken_AreConsistentWithinOneSnippet()
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-abc");
var id = IframeId(snippet);
Assert.That(id, Does.StartWith("deepdrft-embed-"), "id must carry the expected prefix");
// The iframe element must declare the minted id.
Assert.That(snippet, Does.Contain($@"id=""{id}"""),
"iframe element must carry the minted id");
// The script stores the id in a JS var and calls getElementById(id) — confirm the id literal
// appears in the script's var initializer so the right iframe is targeted.
Assert.That(snippet, Does.Contain($@"var id=""{id}"""),
"resize script must initialise its id variable with the same minted id");
}
// The iframe src must carry EmbedId so the iframe content (embed-frame.ts) can read its own
// token and include it in postMessage payloads for the host-side script to match on.
[Test]
public void ForRelease_SrcCarriesEmbedIdParam()
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-def");
Assert.That(snippet, Does.Contain("EmbedId="),
"iframe src must include EmbedId query param so embed-frame.ts can read its own token");
}
private static int HeightOf(string snippet)
{
var match = Regex.Match(snippet, @"height=""(\d+)""");
Assert.That(match.Success, Is.True, "snippet must declare an iframe height");
return int.Parse(match.Groups[1].Value);
}
private static string IframeId(string snippet)
{
var match = Regex.Match(snippet, @"id=""([^""]+)""");
Assert.That(match.Success, Is.True, "snippet must declare an iframe id");
return match.Groups[1].Value;
}
}
+9 -9
View File
@@ -60,9 +60,9 @@ public class MediumWritePathTests
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
@@ -75,7 +75,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
@@ -87,7 +87,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
@@ -105,10 +105,10 @@ public class MediumWritePathTests
var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
@@ -207,9 +207,9 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Description, Is.EqualTo(prose));
Assert.That(result.Value.Release.Description, Is.EqualTo(prose));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
@@ -0,0 +1,292 @@
using System.Text;
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Server-backstop coverage for upload duplicate detection. Drives the full
/// <see cref="UnifiedTrackService.UploadAsync"/> dual-database write over a real temp-isolated
/// <see cref="FileDb"/> vault and an EF in-memory <see cref="DeepDrftContext"/>, so the create-path
/// duplicate block, the within-batch attach path, and the existing single-track cardinality rule are
/// all asserted against the same orchestrator the controller calls.
///
/// The rule under test: a (title, artist) that pre-existed the submit is blocked on the CREATE path
/// (no releaseId), but the within-batch multi-track Cut still succeeds because rows 2..N pass the
/// release id row 1 created (ATTACH path) and so skip the duplicate lookup entirely.
/// </summary>
[TestFixture]
public class UploadDuplicateDetectionTests
{
private string _testDir = string.Empty;
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "UploadDuplicateDetectionTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private TrackManager CreateManager()
{
var repository = new TrackRepository(
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
return new TrackManager(
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
}
private async Task<UnifiedTrackService> CreateUnifiedServiceAsync(ITrackService sqlTrackService)
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var content = new TrackContentService(
fileDatabase!, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
return new UnifiedTrackService(
content, sqlTrackService, fileDatabase!, waveforms,
NullLogger<UnifiedTrackService>.Instance);
}
private async Task<string> WriteWavAsync(double durationSeconds)
{
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(path, BuildMinimalPcmWav(durationSeconds));
return path;
}
private Task<ResultContainer<TrackDto>> UploadAsync(
UnifiedTrackService service, string tempPath, string trackName, string artist,
string? album, ReleaseMedium medium, long? releaseId)
=> service.UploadAsync(
tempPath, trackName, artist, album,
genre: null, description: null, releaseDate: null, createdByUserId: 1,
originalFileName: null, releaseType: ReleaseType.Single, medium: medium,
trackNumber: 1, releaseId: releaseId, ct: default);
// CREATE path: a brand-new single-track Mix succeeds (no pre-existing (title, artist)).
[Test]
public async Task UploadAsync_NewSingleTrackRelease_Succeeds()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Sunset Set", "DJ B", "Sunset Set", ReleaseMedium.Mix, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Not.Null);
}
// CREATE path: uploading a (title, artist) that already exists is blocked with the duplicate marker
// (which the controller maps to 409), for ANY medium — here a Cut.
[Test]
public async Task UploadAsync_DuplicateTitleArtist_IsBlockedWithDuplicateMarker()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True, "the first create must succeed");
// Second submit, same (title, artist), no releaseId → CREATE path → duplicate block.
var duplicate = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(duplicate.Success, Is.False);
var message = duplicate.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
Assert.That(message, Does.Contain("Studio Album"), "the block message names the existing release");
}
// The crux regression guard: a within-batch multi-track Cut. Row 1 CREATEs the release; row 2 passes
// row 1's ReleaseId (ATTACH path) and must succeed — the within-batch release is NOT a pre-existing
// duplicate. Both tracks end up on the same release.
[Test]
public async Task UploadAsync_WithinBatchMultiTrackCut_AttachesAndSucceeds()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var row1 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId: null);
Assert.That(row1.Success, Is.True, "row 1 creates the release");
var releaseId = row1.Value!.ReleaseId;
Assert.That(releaseId, Is.Not.Null);
// Row 2 attaches to the just-created release — same (title, artist), but with the explicit id.
var row2 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId);
Assert.That(row2.Success, Is.True, row2.Messages.FirstOrDefault()?.Message);
Assert.That(row2.Value!.ReleaseId, Is.EqualTo(releaseId), "row 2 lands on the same release row 1 created");
var peek = (await ((ITrackService)manager).GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
Assert.That(peek.TrackCount, Is.EqualTo(2), "both within-batch tracks are on the one release");
}
// The existing single-track cardinality rule still fires on the attach path: a Session already
// holding its one track rejects a second add with the cardinality marker (controller → 409). This
// is reachable here only via an explicit releaseId, since a no-id second submit is the duplicate path.
[Test]
public async Task UploadAsync_SecondTrackOnSingleTrackRelease_IsBlockedWithCardinalityMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Live Set", "DJ A", "Live Set", ReleaseMedium.Session, releaseId: null);
Assert.That(first.Success, Is.True);
var releaseId = first.Value!.ReleaseId;
// A second track aimed at the same single-track Session via its id → cardinality rejection.
var second = await UploadAsync(
service, await WriteWavAsync(2.0), "Second Take", "DJ A", "Live Set", ReleaseMedium.Session, releaseId);
Assert.That(second.Success, Is.False);
var message = second.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.CardinalityViolationMarker));
}
// ATTACH anti-forgery guard: when the caller supplies a releaseId that does NOT match the release
// the natural key (title, artist) resolves to, the upload is rejected. Guards against a stale or
// forged releaseId pointing at a different (title, artist) than this row carries.
[Test]
public async Task UploadAsync_AttachWithMismatchedReleaseId_IsRejectedWithDuplicateMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
// Create two separate releases so we have two distinct ids.
var releaseA = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Release A", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseA.Success, Is.True, "release A must be created");
var idA = releaseA.Value!.ReleaseId!.Value;
var releaseB = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseB.Success, Is.True, "release B must be created");
// Try to ATTACH to release A while carrying release B's (title, artist). The natural-key lookup
// resolves to B — id A ≠ B.Id → anti-forgery guard fires.
var forged = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: idA);
Assert.That(forged.Success, Is.False);
var message = forged.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
}
// Loose-track success: an upload with null/whitespace album stays release-less (ReleaseId null).
// Confirms the duplicate guard is correctly bypassed for tracks that carry no album.
[Test]
public async Task UploadAsync_NullAlbum_SucceedsAsLooseTrack()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Standalone Cut", "DJ Solo", album: null, ReleaseMedium.Cut, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Null, "a null-album track must stay a loose track with no release");
}
// Case-sensitivity caveat: the assertion below verifies ordinal == equality as implemented by the
// EF in-memory provider (which evaluates LINQ predicates in-process). The deployed PostgreSQL
// instance may use a different column collation (e.g. case-insensitive) — production case-sensitivity
// depends on the collation of the `release` table's `title` and `artist` columns, not on this test.
// Matching semantics: GetReleaseByTitleAndArtist (the read both the pre-flight and the create-path
// duplicate guard use) is exact — a case difference is NOT a match, so it does not trip the block.
// This asserts the pre-flight and the create path agree by using the one shared read.
[Test]
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicateOnInMemoryProvider()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True);
// Different case → not the same natural key under the in-memory provider's ordinal == →
// admitted as a new release. Production outcome depends on PostgreSQL column collation.
var differentCase = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "STUDIO ALBUM", ReleaseMedium.Cut, releaseId: null);
Assert.That(differentCase.Success, Is.True, differentCase.Messages.FirstOrDefault()?.Message);
}
// Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale square
// wave (non-silent so the loudness algorithm yields a real envelope). Same layout as
// TrackReplaceAudioTests / WaveformProfileServiceTests.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+65
View File
@@ -0,0 +1,65 @@
using DeepDrftAPI;
namespace DeepDrftTests;
/// <summary>
/// Guards the upload-staging directory resolution (<see cref="Startup.ResolveStagingPath"/>). The
/// load-bearing invariant: large audio bodies must stage on the data disk, never the system temp
/// mount — on the Linux host /tmp is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB WAV.
/// </summary>
[TestFixture]
public class UploadStagingPathTests
{
[Test]
public void ResolveStagingPath_DefaultsToStagingUnderVault_WhenUnconfigured()
{
var vaultPath = Path.Combine(Path.GetTempPath(), "DeepDrftTests", Guid.NewGuid().ToString());
foreach (var configured in new[] { null, "", " " })
{
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.Multiple(() =>
{
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(Path.Combine(vaultPath, "staging"))),
"An unset/blank StagingPath must default to a 'staging' subdirectory under the vault path");
Assert.That(Path.IsPathFullyQualified(resolved), Is.True,
"The resolved staging path must be absolute");
});
}
}
[Test]
public void ResolveStagingPath_HonoursExplicitOverride()
{
var vaultPath = Path.Combine("data", "vaults");
var configured = Path.Combine(Path.GetTempPath(), "DeepDrftTests", "custom-staging", Guid.NewGuid().ToString());
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(configured)),
"An explicit Upload:StagingPath must win over the vault-path default");
}
[Test]
public void ResolveStagingPath_NeverResolvesIntoSystemTempDirectory_ForDataDiskVault()
{
// A production-shaped vault path on the data disk (the real config is a relative "../Database/Vaults").
// The resolved staging dir must sit under that vault, not under Path.GetTempPath() (= /tmp on Linux).
var vaultPath = Path.Combine("..", "Database", "Vaults");
var resolved = Startup.ResolveStagingPath(configuredPath: null, vaultPath);
var systemTemp = Path.GetFullPath(Path.GetTempPath());
// Note: because vaultPath is relative, Path.GetFullPath resolves it against the CWD, which is
// never the system temp directory. The StartsWith guard therefore catches the case where
// ResolveStagingPath mistakenly uses Path.GetTempPath() directly, rather than proving the
// absolute production path never overlaps with /tmp on any machine. The EndsWith assertion
// is the load-bearing check: it verifies the output is rooted under the vault tree, not
// under a hard-coded temp location.
Assert.That(resolved.StartsWith(systemTemp, StringComparison.Ordinal), Is.False,
"The default staging directory must never live under the system temp mount");
Assert.That(resolved, Does.EndWith(Path.Combine("Database", "Vaults", "staging")),
"The default staging directory must hang off the vault path on the data disk");
}
}
+110 -6
View File
@@ -304,18 +304,21 @@ it can begin immediately. **Landed:** 2026-06-19 on dev. 17.2 (docked overlay, e
`MudDropContainer` reorder) and 17.3 (Fixed embed panel + snippet resize — **the OQ1
Option-A-vs-B feasibility call is made here**) hang off it and are largely parallel. Add-to-Queue
split to a standalone 17.4 (needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. 17.3 remains
pending.
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. **Landed
(17.3):** 2026-06-19 on dev.
**Phase 17 is complete.** All four waves (17.1 engine additions + shared `QueueList`, 17.2 docked
overlay, 17.3 Fixed embed panel + iframe resize handshake, 17.4 Add-to-Queue affordance) landed on
dev 2026-06-19. See `COMPLETED.md §17` for the full completion records.
Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
the open-question set: `product-notes/phase-17-player-queue-view.md`.
**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**
- **OQ1****Option A, conditional** — collapse/expand toggle *if* the embed snippet can dynamically
resize the iframe (`postMessage` → host resize handshake), **else fall back to Option B** (omit the
button); A preferred, B fallback, deciding factor = iframe-resize feasibility, **determined during
17.3**.
- **OQ1****Option A, confirmed (17.3)** — collapse/expand toggle with `postMessage` → host resize
handshake implemented. `EmbedSnippetBuilder.ForRelease` carries the host-side listener; `embed-frame.ts`
posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
- **OQ2****yes, both modes** — clicking a queued row jumps playback to that track in the docked
overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove
@@ -339,6 +342,107 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
---
## Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS — the admin
user-administration surface (provision users, manage accounts, manage registration invites, manage role
permissions) **and** the public-facing self-service registration form. **All three paths live on
`DeepDrftManager` (the CMS app); there are NO changes to `DeepDrftPublic` in this phase.** Daniel's
framing: *"already part of the AuthBlocks library so we just wire it up."* Correct — and **further along
than it implies**: almost everything landed by side-effect of the prior startup separation. Full design,
the verified three-path model, the already-done-vs-remaining split, the SkipperHaven pattern + concrete
deltas, scope boundaries, and open questions: `product-notes/phase-19-user-management-cms.md`.
**The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:**
1. **Admin provisions directly**`SuperRegister.razor``/account/superregister` → `POST
api/auth/admin-register` (UserAdmin-gated, **working**). Creates a live account now.
2. **Public self-service**`Register.razor``/account/register``POST api/auth/register`
(**unauthenticated, no role gate, working**). A **public-facing CMS route, exactly like the CMS
`/account/login` page** — invited user redeems a code (pre-filled from the invite email's deep link)
and self-registers, all on the CMS host.
3. **Admin provisions a token + triggers the invite email**`NewRegistration(Form).razor`
`/useradmin/registrations/new``POST api/pendingregistration/create` (UserAdmin-gated). **Sends a
real email server-side** via Mailtrap (`RegistrationEmailTemplate` + `IGeneralEmailSender`, configured
in DeepDrftAPI from `environment/authblocks.json`) — **not stubbed.**
**Host-model correction (Daniel, 2026-06-19).** A prior revision placed public registration (path 2) on
`DeepDrftPublic` as a cold-start integration. **Wrong — there are NO `DeepDrftPublic` changes.** Public
registration is an unauthenticated route *on the CMS app*, mirroring the CMS's already-public
`/account/login`. The only genuinely stubbed surface is **Reset Password** (`Users.razor`, `// todo`; **no
backing endpoint** in `AuthRoutes`) — handled separately by Daniel in the AuthBlocks repo (see
`product-notes/authblocks-password-reset-brief.md`).
**Most wiring already landed by side-effect.** The AuthBlocks startup separation
(`PLAN_authblocks_trackmanager.md`, 2026-05-25) + login/logout integration already put the entire surface
in place on `DeepDrftManager`: `Cerebellum.AuthBlocks.Web` referenced, `ConfigureAuthServices` registers
every client + ViewModel **and** the `JwtAuthenticationStateProvider` path 2 needs, the router discovers
every page (`AdditionalAssemblies`) — **including the public `/account/register`** — and the DeepDrft
`Admin` role **inherits** `UserAdmin` (the seeded admin passes the gate with no change). The pages ship in
a published **RCL**, so the worried-about "extract pages into an RCL" fork **does not arise**.
**Two real gaps remain.** (a) **No nav**`CmsLayout` is just an app bar + Home button, so nothing links
to `/useradmin/*` or `/account/superregister` (admin surface invisible). (b) **Wrong layout for public
pages** — `Routes.razor` uses a **static** `DefaultLayout="typeof(CmsLayout)"`, so an unauthenticated
visitor to `/account/register` (or `/account/login`) lands in the authenticated app shell instead of the
lean splash.
**SkipperHaven is the canonical pattern.** `SkipperHaven` (same AuthBlocks library) exposes login +
register as public/unauthenticated routes correctly by making `Routes.razor`'s `DefaultLayout`
**auth-state-driven** — unauthenticated → home/lean layout, authenticated → app shell (resolved in
`OnParametersSetAsync` off the cascaded `AuthenticationState`). **The concrete delta DeepDrftManager
needs is exactly one change** (spec §2c): make its `DefaultLayout` auth-state-driven, resolving
`CmsHomeLayout` (unauth) vs. `CmsLayout` (auth). Everything else SkipperHaven does — service wiring, page
discovery, both layouts — DeepDrftManager **already has** (it even already ships `CmsHomeLayout`, used by
the `/` home splash). So path 2 is **one router edit**, not a host integration.
**One host (`DeepDrftManager`), two parallel tracks** (different files), then verify + theme.
- **19.1 — CmsLayout navigation (admin-nav track; the main code wave). DECIDED nav shape: G1-b.** Add a
`MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment (self-gates to
`UserAdmin`+) alongside the existing CMS destinations (Catalogue / Releases / Upload); surface **both**
admin account paths (path 1 `SuperRegister` + path 3 via the Registrations link); do **not** surface the
redundant bare `NewUser` (OQ2 resolved). Scope: `CmsLayout.razor`. **No service, API, data, or
AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
`Routes.razor`'s `DefaultLayout` auth-state-driven (mirroring SkipperHaven, spec §2c D1): cascade
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
`DefaultLayout="@_currentLayout"`. This renders `/account/register` (path 2) **and** `/account/login` in
the lean `CmsHomeLayout` for unauthenticated visitors. Scope: `Routes.razor` only. **No new layout (both
exist), no package, no service, no AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise provision-now (path 1), **invite-email
send (path 3) incl. that the invite link `{ReturnHost}` points at the CMS origin**, list/deactivate
users, permissions against a running DeepDrftAPI; confirm cross-host token + CORS, and **the full
path-3→path-2 loop on the single CMS host** (admin provisions → email arrives → invitee redeems on the
CMS `/account/register` in the lean layout). Mostly test; any break is likely a one-line config fix
(esp. Mailtrap creds + return host) or an upstream AuthBlocks issue.
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Accept the CMS palette
for the MudBlazor-default grids and the public pages now in `CmsHomeLayout`; fix only contrast/legibility
breaks. Bespoke restyle deferred.
**Deferred (note, don't build):** admin dashboard landing (G1-c); working **Reset Password** (separate
AuthBlocks-repo effort); bespoke restyle of the AuthBlocks grids; a visible public Register nav link
(invite-only — the email deep link is the entry point); bumping `Cerebellum.AuthBlocks.Web` 10.3.33 →
10.3.35 (housekeeping).
**Explicitly not needed:** any change to `DeepDrftPublic` (corrected host model — all three paths are CMS);
extracting AuthBlocks pages into a new RCL; new DI/service wiring, role seeding, or Auth connection string
(all present); editing the AuthBlocks `Login`/`Register` pages' layout (impossible without forking the
RCL — G0-a fixes layout host-side instead).
**Open questions for Daniel (spec §6).** *Resolved:* (1) nav shape **G1-b**; (2) surface path 1 + path 3,
hide bare `NewUser`; (5) Reset Password non-functional in v1, handled separately; (6) **host model — all
three on the CMS, no `DeepDrftPublic` changes**; (7) **public-route layout G0-a** (auth-state-driven
`DefaultLayout`, reusing `CmsHomeLayout`). *Still open:* (3) admin dashboard defer (recommend defer); (4)
package bump (recommend leave); (8) a logged-in admin visiting `/account/register` sees it in the app
shell under G0-a (recommend accept). None block 19.1 or 19.2.
**Adjacency to the deferred Identity / accounts backlog item (below).** That item is about *public,
per-user* identity (favourites, listening history, playlists). This phase is *CMS* account management only
(admin surfaces + invite-based self-registration) — same AuthBlocks substrate, different surface. They are
not the same work; this phase does not satisfy or depend on that one.
---
## 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 15. Phase numbers are organisational, not sequencing.
@@ -0,0 +1,240 @@
# Team Brief — Email-Backed Password Reset for AuthBlocks
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
consume AuthBlocks. Everything you need is in this brief or in that repo.
**Status:** scoped request, not yet started. Author: product-designer (for a downstream consumer team).
Date: 2026-06-19.
---
## 1. The goal in one sentence
Replace the non-functional "Reset Password" stub on the AuthBlocks user-administration **Users** page
with a real, email-backed password-reset flow — so that triggering "Reset Password" for a user sends
that user an email containing a secure, time-limited reset link, and following the link lets them set a
new password.
This is an **upstream library feature**, delivered entirely inside AuthBlocks and published as a normal
version bump. Consumers pick it up by referencing the new package version.
---
## 2. Where the stub lives today
`AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the user grid has a per-row **Reset
Password** `MudButton` whose handler is empty:
```csharp
private async Task ResetPassword(UserInputModel? item)
{
// todo integrate with email for secure reset
}
```
There is **no backing API endpoint** for this action. `AuthBlocksLib/Routes/AuthRoutes.cs` maps
`login`, `register`, `admin-register`, `refresh`, `logout`, `me`, `roles` — and nothing for password
reset. So this is a build-from-scratch flow on both the API side (new endpoints) and the Web side
(wire the button + add a public reset page), reusing AuthBlocks' existing email and token machinery.
---
## 3. What AuthBlocks already has that you should reuse
**The pending-registration flow is your template.** AuthBlocks already does almost exactly this shape
of work for invitations — generate a secure token, email a link, validate the token when the user
returns. Read it end-to-end before designing reset; you are building a sibling flow:
- **Email sending is real and wired.** `AuthBlocksLib/AuthBlocksExtensions.cs` (~line 109) registers
`services.AddScoped<IGeneralEmailSender, MailtrapEmailSender>();`. The `IGeneralEmailSender`
abstraction and `MailtrapEmailSender` implementation come from the shared NetBlocks library
(namespace `API.Common.Email.Mailtrap`). The send signature in use is:
```csharp
await emailSender.SendEmailAsync(toAddress, cc: null, subject, htmlBody);
```
See it called for real at `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:124`.
- **Email connection config.** The host populates `AuthBlocksOptions.EmailConnection` (a NetBlocks
`EmailConnection` with `Host` + `Token`) plus `ApplicationName` and `SupportEmail` when it calls
`AddAuthBlocks(options => { ... })`. Those flow into `AuthBlocksExtensions` and are available to your
reset endpoint exactly as they are to the registration endpoint. **You do not need to invent any new
config or sender** — reuse `IGeneralEmailSender` and `AuthBlocksOptions`.
- **An HTML email template pattern.** `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` is a static
`Create(token, link, applicationName, supportEmail)` returning a styled HTML string. Build a sibling
`PasswordResetEmailTemplate.Create(...)` in the same file's neighbourhood and the same house style
(the registration template is teal-branded, table-layout, support-line-collapses-when-empty — match
it). Do **not** reuse the registration template verbatim; the copy is invitation-specific.
- **A token service pattern.** `AuthBlocksLib/Services/RegistrationTokenService.cs` generates a random
token, SHA-256-hashes `{email}::{token}`, persists the hash with a 7-day expiry, and validates /
consumes it. **However — for password reset, prefer ASP.NET Identity's built-in reset token** (see
§4) rather than re-implementing this hand-rolled scheme. The registration token service is a *style*
reference for endpoint shape and email-link construction, not necessarily the token mechanism.
- **The deep-link construction idiom.** The registration flow builds its link with
`QueryHelpers.AddQueryString(returnHost, { UserEmail, RegistrationToken })` and the public register
page reads those query params and pre-fills (`Register.razor`, `[SupplyParameterFromQuery]`). Mirror
this for the reset page: link carries `email` + `resetToken`; the reset page reads them.
- **Identity is fully present.** `UserService` wraps `UserManager<ApplicationUser>` (see
`AuthBlocksData/Services/UserService.cs`). `UserManager` gives you the canonical reset primitives —
use them.
---
## 4. Recommended mechanism: ASP.NET Identity's built-in reset token
Password reset is a solved problem in ASP.NET Identity, and rolling your own token store for it is an
avoidable security surface. **Strong recommendation:** use `UserManager<ApplicationUser>`'s built-in
reset tokens rather than the hand-rolled `RegistrationTokenService` SHA-256 scheme.
- `var token = await userManager.GeneratePasswordResetTokenAsync(user);` — produces a token bound to
the user's security stamp; invalidated when the password changes or the stamp rotates.
- `var result = await userManager.ResetPasswordAsync(user, token, newPassword);` — validates and
applies in one call; enforces the configured password policy.
- Token lifetime is governed by `DataProtectionTokenProviderOptions.TokenLifespan` (default 1 day) —
confirm/configure to a sensible reset window (recommend 12 hours for reset, tighter than the 7-day
registration window).
This means you likely **do not** need a new DB table or migration for reset (unlike registration,
which persists pending rows). Confirm whether the default token providers are registered in the
AuthBlocks Identity setup; if `AddDefaultTokenProviders()` (or equivalent) is not already called in the
Identity configuration, add it — that is the one wiring prerequisite for `GeneratePasswordResetTokenAsync`
to work.
*Alternative considered (and not recommended):* extend `RegistrationTokenService` / `PendingRegistration`
into a generic token table that also serves reset. Rejected — it couples two unrelated flows, re-implements
what Identity already does correctly, and adds a migration for no benefit. Use it only if there is a
hard reason the Identity token provider cannot be enabled in this setup.
---
## 5. The surfaces to build
Three pieces, mirroring the registration flow's API-endpoint + email-template + web-page triad.
### 5.1 API endpoints (`AuthBlocksLib/Routes/AuthRoutes.cs`)
Add to the `api/auth` group. Two endpoints, both **unauthenticated** (a user resetting a forgotten
password is by definition not logged in — the admin "Reset Password" button triggers the *first* of
these on the user's behalf, but the endpoint itself authenticates via the token, not a bearer):
1. **`POST api/auth/forgot-password`** — body `{ email, returnHost }`. Looks up the user; if found,
generates a reset token and emails the reset link (`{returnHost}?email=&resetToken=`). **Always
return success** regardless of whether the email exists — do **not** leak account existence (a known
reset-flow security requirement; the registration flow's "user already exists" message is acceptable
for an *admin-gated* invite but a *public* forgot-password must not reveal it). On email-send failure,
log and return a generic failure.
2. **`POST api/auth/reset-password`** — body `{ email, resetToken, newPassword }`. Resolves the user,
calls `ResetPasswordAsync(user, token, newPassword)`, returns the Identity result mapped to the
AuthBlocks `Result`/`ApiResult` convention (see how `Register` maps results in `AuthRoutes.cs`).
Follow the existing `AuthRoutes` conventions exactly: `ApiResult<T>` / `ApiResultDto<T>` wrapping,
`ILogger<AuthLogger>` for logging, `Results.Ok` / `Results.BadRequest` / `Results.Json(..., 500)` shapes.
### 5.2 Email template (`AuthBlocksLib/Common/PasswordResetEmailTemplate.cs`)
New static `Create(resetLink, applicationName, supportEmail)` in the visual style of
`RegistrationEmailTemplate`. Reset copy: a clear "you (or an admin) requested a password reset," the CTA
button to the reset link, an expiry notice matching the token lifespan, and "ignore this email if you
didn't request it." No registration code box — reset uses an opaque token in the link, not a
user-typed code (recommended; do not show the Identity token as a copy-paste code — it is long and
URL-encoded).
### 5.3 Web surfaces (`AuthBlocksWeb`)
- **Wire the admin button.** In `Users.razor`, replace the empty `ResetPassword` handler with a call to
an `IAuthApiClient` (or the appropriate existing client) method that hits `POST api/auth/forgot-password`
for `item.Email`, and show a confirmation (a `StatusMessage` / dialog: "Reset email sent to {email}").
This is the admin-initiated trigger.
- **Add a public reset page.** New `AuthBlocksWeb/Components/Pages/Account/ResetPassword.razor`,
`@page "/account/reset-password"`, `@rendermode InteractiveServer`, **no role gate** (a forgotten-password
user is unauthenticated). Read `email` + `resetToken` from query params (mirror `Register.razor`'s
`[SupplyParameterFromQuery]` pre-fill), present new-password + confirm fields, submit to
`POST api/auth/reset-password`, and on success route to `/account/login` with a success message. Match
`Register.razor`'s form structure and validation idiom.
- **Optional: a public "forgot password?" entry.** Consider a `/account/forgot-password` page (link from
`Login.razor`) where a user enters their email to self-initiate reset — same `forgot-password` endpoint.
Decide whether this is in scope or whether reset is admin-initiated only (see open questions).
### 5.4 Client method
Add the `forgot-password` / `reset-password` calls to whichever API client the Web project uses for auth
(the registration/login flows go through `JwtAuthenticationStateProvider` / `IAuthApiClient` — follow the
same pattern; do not introduce a new HTTP client).
---
## 6. Constraints
- **No account-existence leak** on the public `forgot-password` path (§5.1).
- **Reuse, don't reinvent:** `IGeneralEmailSender` for sending, `AuthBlocksOptions` for config, Identity's
token provider for tokens, the existing `Result`/`ApiResult` conventions for endpoint returns, and the
`RegistrationEmailTemplate` house style for the email.
- **Match the existing route + result conventions** in `AuthRoutes.cs` precisely — this is a library;
consumers rely on the shape staying idiomatic.
- **Versioning:** this lands as a normal AuthBlocks version bump (packed/pushed by `pack.ps1` like the
other packages). Note the new version so consumers can pin to it.
- **Token lifespan** for reset should be short (recommend 12 hours), distinct from the 7-day
registration token.
- **Password policy** is enforced by `ResetPasswordAsync` automatically — do not duplicate validation,
but surface the Identity error messages back through the result.
---
## 7. Acceptance criteria
1. Clicking "Reset Password" for a user on the Users admin page sends that user a styled email with a
working reset link, and shows the admin a confirmation. No unhandled exception, no silent no-op.
2. Following the reset link lands on `/account/reset-password` with the email pre-filled; setting a new
password that meets policy succeeds and the user can immediately log in with the new password.
3. An expired or tampered token is rejected with a clear, non-leaky error.
4. The public `forgot-password` endpoint returns the same response whether or not the email maps to a
real account (no existence leak).
5. Email send is exercised through the real `IGeneralEmailSender` (Mailtrap in the configured
environment) — verify an email actually arrives.
6. No new required config beyond what `AddAuthBlocks` already accepts (reset reuses the existing email
connection + application-name + support-email options). If a token-provider registration was missing,
it is added and documented.
7. Published as a version bump; the new version is recorded.
---
## 8. Open questions for the implementing team / its sponsor
1. **Admin-initiated only, or also public self-serve?** Is the only entry point the admin "Reset
Password" button (§5.3 first bullet), or do you also want a public "forgot password?" link from the
login page (§5.3 last bullet)? The endpoints support both; the question is which Web surfaces to build.
*Recommendation: build both endpoints, ship the admin button now, and add the public forgot-password
page in the same pass since it is nearly free once the endpoint exists.*
2. **Token mechanism — confirm Identity's built-in is acceptable** (§4 recommendation) vs. a hard
requirement to use the hand-rolled hashed-token scheme. *Recommendation: Identity built-in.*
3. **Reset token lifespan** — confirm the window (recommend 12 hours).
4. **Return host / link base** — the registration flow has the *caller* pass `returnHost`. Confirm the
reset flow does the same (the consumer supplies the base URL of its public reset page), vs. AuthBlocks
configuring a reset base URL in options. *Recommendation: pass `returnHost` per-call, mirroring
registration, so AuthBlocks stays host-agnostic.*
5. **Does the public reset page (`/account/reset-password`) need to render in a consumer's own layout?**
The page ships in the AuthBlocks RCL with no `@layout`, so it inherits whatever the consuming host sets
as default — same as `Register.razor`. Confirm this is acceptable (it should be; it is how registration
already behaves).
---
## 9. Suggested reading order in the repo
1. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the email-sending endpoint to mirror.
2. `AuthBlocksLib/Routes/AuthRoutes.cs` — where your endpoints go; the result/logging conventions.
3. `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` — the email house style.
4. `AuthBlocksWeb/Components/Pages/Account/Register.razor` — the public-page + query-param-prefill pattern
for your reset page.
5. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the stub to replace.
6. `AuthBlocksLib/AuthBlocksExtensions.cs` + `AuthBlocksOptions.cs` — the email sender + options wiring you
reuse (and where to add a token-provider registration if one is missing).
7. `AuthBlocksData/Services/UserService.cs` — the `UserManager<ApplicationUser>` access point for the
Identity reset primitives.
@@ -0,0 +1,38 @@
# Index — `EditModalSaveContextHolder` Missing DI Registration (BlazorBlocks / AuthBlocks)
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped, not yet started. Confirmed against `Cerebellum.BlazorBlocks.Web` 10.3.32 / `Cerebellum.AuthBlocks.Web` 10.3.33. Author: product-designer. Date: 2026-06-19.~~
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
> This brief is retained as historical record — no further action required.
## The defect
BlazorBlocks' `ModelView` / `EditModelModal` components have a `required [Inject]` dependency on
`Web.Maintenance.Entities.EditModalSaveContextHolder` (a per-circuit save bridge), but the BlazorBlocks
`Web` package ships no registration extension for it and AuthBlocks' `ConfigureAuthServices` never registers
it either. Any consumer of a `ModelView`-based page (e.g. AuthBlocks' `Users.razor` /
`Registrations.razor`) crashes the Blazor circuit on navigation with an unregistered-service
`InvalidOperationException`. Two independent downstream products have each hand-registered the internal
service as a stopgap — the tell that this is a leaked library registration.
## The two-team layered fix (ordered — do not reorder)
1. **BlazorBlocks ships first.** Add a Web-side `AddBlazorBlocksWeb()` extension that registers the holder
via `TryAddScoped` (scoped is required). Bump `Cerebellum.BlazorBlocks.Web` from 10.3.32; report the new
version.
2. **AuthBlocks ships second.** Bump its `Cerebellum.BlazorBlocks.Web` reference to that new version, call
`AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bump `Cerebellum.AuthBlocks.Web` from 10.3.33.
AuthBlocks is blocked until BlazorBlocks' new version is published. Registration lives with its owner
(BlazorBlocks); AuthBlocks stays self-contained by composing it. MudBlazor (`AddMudServices`) stays a
caller-owned prerequisite throughout.
## The detail lives in the two team briefs
Each is fully self-contained for an orchestrator working in only that one repo:
- **BlazorBlocks team** → [`team-brief-blazorblocks-modelview-di.md`](./team-brief-blazorblocks-modelview-di.md)
(root cause, `[Inject]` audit, lifetime rationale, the new extension, version bump, acceptance criteria).
- **AuthBlocks team** → [`team-brief-authblocks-modelview-di.md`](./team-brief-authblocks-modelview-di.md)
(the blocking BlazorBlocks prerequisite, the `ConfigureAuthServices` call, version bump, acceptance
criteria).
@@ -0,0 +1,464 @@
# Phase 19 — AuthBlocks User Management in the CMS
Status: proposed (rev. 3 — host model corrected by Daniel 2026-06-19). Author: product-designer.
Date: 2026-06-19. Implementer: TBD (separate delegation).
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS so an admin can run
account management from inside the same CMS they already use, **and** so an invited user can redeem a
registration code and create their own account — **all on `DeepDrftManager` (the CMS app,
demoapp.deepdrft.com)**. There are **no changes to `DeepDrftPublic` in this phase.**
Daniel's framing: *"this is already part of the AuthBlocks library so we just need to wire it up
properly."* **That framing is correct, and the wiring is further along than it implies.** Almost the
entire integration already landed as a side-effect of the prior AuthBlocks startup separation
(`PLAN_authblocks_trackmanager.md`, landed 2026-05-25) and the login/logout integration; what remains
is a thin **navigation + public-route-exposure + verification + polish** slice, not an integration
project.
**Host-model correction (Daniel, 2026-06-19 — the crux of this revision).** Rev. 2 placed public
self-service registration (path 2) on `DeepDrftPublic` as a cold-start integration. **That was wrong.**
Public registration belongs on the **CMS app**, exactly where login already lives: the CMS app
*already hosts a public-facing, unauthenticated `/account/login` page* (reachable without being signed
in). The registration redemption page is public-facing **in exactly the same way** — an unauthenticated
route on the CMS app itself. An invited user clicks the email link, lands on the CMS app's public
registration route (`/account/register`), redeems their code, sets a password. **No second host, no
`DeepDrftPublic` involvement.** The entire rev-2 "public-site track" (wave 19.4) and its open questions
(OQ6OQ9) are **deleted** — they were artifacts of the wrong host assumption.
So all three paths live on `DeepDrftManager`. The real remaining questions for path 2 are narrow: it is
likely already *route-reachable* (the CMS router discovers `/account/register` via `AdditionalAssemblies`,
same as it discovers `/account/login`), so the work is (a) confirming it is correctly **unauthenticated**
(no role gate — verified below, it has none), and (b) giving an unauthenticated visitor the **right
layout** (the lean splash chrome, not the authenticated app shell), mirroring how login should render.
SkipperHaven — another app on the **same AuthBlocks library** — already implements this dual public
login/register pattern, and is the canonical reference (§2c).
---
## 0. The three account-creation paths (verified against AuthBlocks source) — ALL on the CMS
Verified against `C:\Development\AuthBlocks` source. Daniel's three-path understanding is **correct and
complete**, and all three are CMS routes:
| # | Path | Component(s) | Route | Gate | Backed by | Email? |
|---|------|--------------|-------|------|-----------|--------|
| 1 | **Admin provisions a user directly** (bypasses email/code loop) | `SuperRegister.razor` | `/account/superregister` | UserAdmin | `POST api/auth/admin-register`**working** | No |
| 2 | **Public self-service** — invited user redeems a code and self-registers | `Register.razor` | `/account/register` | **none (public)** | `POST api/auth/register`**working** | No (consumes code) |
| 3 | **Admin provisions a registration token + triggers the invite email** | `NewRegistration.razor``NewRegistrationForm.razor` | `/useradmin/registrations/new` | UserAdmin | `POST api/pendingregistration/create`**working, sends email server-side** | **Yes — real, not stubbed** |
All three are **CMS routes on `DeepDrftManager`.** Paths 1 and 3 are admin-gated (UserAdmin). Path 2 is
**public-facing**, reachable by an unauthenticated visitor — exactly like the CMS `/account/login` page,
which is also unauthenticated and on the same host.
**Path 2 has no role gate (verified).** `Register.razor` declares `@page "/account/register"` +
`@rendermode InteractiveServer` and **no** `[HierarchicalRoleAuthorize]` attribute — identical in this
respect to `Login.razor` (`@page "/account/login"`, no gate). It reads `UserEmail` + `RegistrationToken`
from the query string and pre-fills, so the invite email's deep link lands ready to submit; it calls
`AuthStateProvider.RegisterAsync``POST api/auth/register`. It is meant to be reached by an
unauthenticated visitor.
**Path 3's email is real.** `PendingRegistrationRoutes.Create`
(`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:62`) generates a token, persists the pending
registration, builds the invite link (`{ReturnHost}?UserEmail=&RegistrationToken=`), renders
`RegistrationEmailTemplate.Create(...)`, and **sends it via `IGeneralEmailSender.SendEmailAsync`**
a Mailtrap-backed `MailtrapEmailSender` registered in `AuthBlocksExtensions` (line 109) and configured
in **DeepDrftAPI** from `environment/authblocks.json` (`AuthBlocks:Email:Host` / `:Token`,
`Program.cs:106109`; `ApplicationName="DeepDrft"`, `SupportEmail` from config). On email-send failure
the route **rolls back** the pending-registration row and returns 500. The full invite→email→redeem
loop is functional end-to-end across paths 2 and 3, **entirely within the CMS host**: an admin
provisions (path 3, CMS) → the prospective user receives an email with a code + link → they land on the
CMS app's public `/account/register` (path 2, CMS) with email + token pre-filled → they set a password
and the account is created.
> **Note on `{ReturnHost}`.** The invite email's deep link is built from a configured return host. For
> the loop to land on the CMS app, that host must point at the CMS origin (demoapp.deepdrft.com), not the
> public site. Verify this config value in 19.3 (it is the one place the wrong-host assumption could be
> baked into a *config* rather than code).
**The one genuinely stubbed surface is Reset Password** — `Users.razor:55` (`// todo integrate with
email for secure reset`) has an empty handler and **no backing API endpoint exists** (`AuthRoutes`
maps login/register/admin-register/refresh/logout/me/roles — no reset route). That is the subject of
the separate `authblocks-password-reset-brief.md`; it must **not** be filed as a DeepDrft bug.
**Two distinct admin "create" verbs — both stay, they are not duplicates.** `SuperRegister` (path 1)
creates a *live account immediately* with a password the admin sets. The registration-token form (path
3) creates a *pending invite* — no account yet — and lets the user set their own password via email. They
serve different needs (provision-now vs. invite-by-email); both belong in the CMS nav. (The older
`NewUser` `ModelView` create form at `/useradmin/users/new` still exists as a third bare admin create
path, but it is **not** one of Daniel's three; treat it as redundant with `SuperRegister` and do not
surface it in nav. See OQ2.)
---
## 1. What AuthBlocks ships, and how it is packaged
Read from local source at `C:\Development\AuthBlocks`. The key question — *is the user-admin surface
consumable or host-bound?* — resolves cleanly: **it is consumable.**
### The user-admin surface is a published RCL, despite the "Web" name
`AuthBlocksWeb` is an `Microsoft.NET.Sdk.Razor` project (not `Sdk.Web`) with **no `Program.cs`** — it
is a Razor Class Library, not a runnable host. `pack.ps1` packs it as **`Cerebellum.AuthBlocks.Web`**
and pushes it to nuget.org. So the user-admin Razor components are distributed as a normal RCL and
consumed by reference. **No extraction fork is needed** — the pages are already in the RCL.
### What's in the package (the consumable surface)
Components under `AuthBlocksWeb/Components/`:
- **Account pages** (`Pages/Account/`):
- `Login.razor``/account/login` (**public — no gate, no `@layout`**; `@rendermode InteractiveServer`).
- `Register.razor``/account/register` (**path 2** — public self-service via invite code; `@rendermode
InteractiveServer`; **no role gate, no `@layout`**; reads `UserEmail` + `RegistrationToken` from the
query string and pre-fills; calls `AuthStateProvider.RegisterAsync``POST api/auth/register`).
- `Logout`, `AccessDenied`.
- `SuperRegister.razor``/account/superregister` (**path 1** — admin creates a live account
immediately, role multiselect; gated `[HierarchicalRoleAuthorize(UserAdmin)]`; calls
`IAuthApiClient.AdminRegisterAsync``POST api/auth/admin-register`).
- **User admin pages** (`Pages/UserAdmin/`), each `@page`-routed and gated
`[HierarchicalRoleAuthorize(SystemRoleConstants.UserAdmin)]`:
- `Users/Users.razor``/useradmin/users` — searchable user grid; per-row Reset Password
(**stubbed — no backing endpoint**), Deactivate/Reactivate, edit modal.
- `Users/NewUser.razor``/useradmin/users/new` — bare create-user form (redundant with `SuperRegister`;
not one of Daniel's three paths — do not surface in nav).
- `Registrations/Registrations.razor``/useradmin/registrations` — pending-invite grid, with
`NewRegistration.razor``/useradmin/registrations/new` (**path 3** — `NewRegistrationForm` posts to
`PendingRegistrationClient.CreatePendingRegistration``POST api/pendingregistration/create`, which
mints the token **and sends the invite email**) and the edit-registration modal.
- `Permissions/Permissions.razor``/useradmin/permissions` — user↔role assignment.
- **Menu fragments** (`Components/Layout/`): `AccountNavMenu`, `UserAdminMenu` (a `MudNavGroup`
with the three user-admin `MudNavLink`s, itself wrapped in a `HierarchicalRoleAuthorizeView` so it
only renders for `UserAdmin`+).
- **Shared** (`Components/Shared/`): `LogoutButton`, `StatusMessage`.
- **DI entry point** (`Startup.cs`): `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)`
registers the cascading auth state, the JWT client stack, **and every user-admin client + ViewModel**,
all pointed at `apiBaseUrl`.
The pages lean on `Cerebellum.BlazorBlocks.Web` for grid scaffolding and MudBlazor for chrome — both
already present in the CMS.
### The API side is already hosted
The clients those ViewModels use call the AuthBlocks **API** surface, which `DeepDrftAPI` already
mounts via `app.MapAuthBlocks()` (`Program.cs:184`): `api/auth/*` (incl. `admin-register`, `register`,
`roles`), `api/users/*`, `api/roles/*`, `api/user-roles/*`, `api/pendingregistration/*`. `AddAuthBlocks`
+ `UseAuthBlocksStartupAsync` (migrate + seed) are wired, and the Auth DB + secrets live in
`DeepDrftAPI/environment/`. This all landed with the startup separation.
---
## 2. What is ALREADY wired in DeepDrftManager (do not redo)
Verified against the current `DeepDrftManager` source. These are the integration steps a naive plan
would propose — and they are **already done**:
1. **Package reference.** `DeepDrftManager.csproj:11` references `Cerebellum.AuthBlocks.Web` (10.3.33),
which transitively brings `AuthBlocksWeb.Client`, `AuthBlocksLib`, `AuthBlocksModels`.
2. **Service wiring.** `Program.cs:35` calls
`AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, contentApiUrl)` — the user-admin
clients and ViewModels are **already in the container**, already pointed at DeepDrftAPI. **This same
wiring also registers the `JwtAuthenticationStateProvider` that `Register.razor` (path 2) depends on**
— so path 2's service dependency is already satisfied (it is the same provider login uses).
3. **Page discovery.** `Routes.razor:2` sets
`AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"` and `Program.cs:131`
mirrors it for endpoint mapping. **The Blazor router already discovers every AuthBlocksWeb page**,
including `/account/login`, `/account/register`, `/account/superregister`, and the `/useradmin/*`
pages. They are route-reachable *today* by typing the URL — **including the public `/account/register`.**
4. **Default layout.** `Routes.razor:6` sets `DefaultLayout="typeof(Layout.CmsLayout)"`. Since the
AuthBlocks pages declare no `@layout`, they **render inside CmsLayout chrome** — the authenticated app
shell. **This is the one wrong thing for the public pages** (login, register): an unauthenticated
visitor sees the full authenticated CMS shell rather than the lean splash. See §2b.
5. **Role gating already satisfied.** The admin pages gate on `SystemRoleConstants.UserAdmin`. The
DeepDrft admin is seeded in role **`Admin`**, parent of `UserAdmin` — hierarchical authorize means
**the existing admin already passes the `UserAdmin` gate** with no role change, no new seed, no DB edit.
6. **Auth-state + redirect plumbing.** `AuthorizeRouteView` with `RedirectToLogin` /
`RedirectToAccessDenied` (`Routes.razor`) already protects the gated surface coherently, and the
public pages (no gate) pass straight through it.
**Net:** an authenticated DeepDrft admin can navigate to `/useradmin/users` today and the page should
render and call DeepDrftAPI; and an unauthenticated visitor can reach `/account/register` today. The
reasons it *feels* unbuilt: (a) **nothing in the CMS UI links to the admin pages**`CmsLayout` has no
nav drawer at all, so the admin surface is invisible (§G1); and (b) **the public pages render in the
wrong (authenticated-shell) layout** for an unauthenticated visitor (§G0/§2b).
This is the crux: the CMS work is not *integration*, it is *exposure + layout-fix + verification +
fit-and-finish*.
---
## 2b. The public-route layout gap (path 2 + login) — the one real public-facing fix
The public pages — `/account/login` and `/account/register` — are route-reachable and unauthenticated,
but DeepDrftManager's router uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"`. Because the
AuthBlocks public pages declare no `@layout`, an **unauthenticated visitor** lands inside the
**authenticated app shell** (`CmsLayout` — the dense admin app bar with a Catalogue/Home button, and
soon a nav drawer linking to gated admin surfaces). That is the wrong frame: a visitor who is not
signed in should see the lean splash chrome the site already uses for its `/` home splash
(`CmsHomeLayout`), not the admin shell.
DeepDrftManager **already has both layouts**:
- `CmsLayout` — the authenticated app shell (`MudThemeProvider` + app bar + main content; gains the nav
drawer in 19.1).
- `CmsHomeLayout` — the lean splash (`MudThemeProvider` + minimal app bar, centered narrow container),
already used by `Home.razor` (`@layout Layout.CmsHomeLayout`) for the unauthenticated `/` splash.
The fix is to render the **public auth pages in the lean layout** for unauthenticated visitors, and the
**gated pages in the app shell** — exactly the SkipperHaven pattern (§2c). The two clean shapes:
- **G0-a — Auth-state-driven `DefaultLayout` in `Routes.razor` (the SkipperHaven pattern; recommended).**
Make the router's `DefaultLayout` a function of auth state: unauthenticated → `CmsHomeLayout`,
authenticated → `CmsLayout`. This is exactly what SkipperHaven does (its `Routes.razor` swaps
`AuthenticatedLayout`/`UnauthenticatedLayout` in `OnParametersSetAsync` off the cascaded
`AuthenticationState`). **DeepDrftManager already has both target layouts**, so this is a small
router change, no new layout to author. *Cost:* the gated admin pages also resolve their layout via
this switch — but an unauthenticated visitor to a gated page is redirected to login by `NotAuthorized`
before layout matters, and an authenticated admin gets `CmsLayout`, so it composes correctly.
*Caveat:* a logged-in admin who visits `/account/register` would see it in `CmsLayout` (the app shell)
— acceptable, and arguably correct (an admin poking at the public form is in an admin session).
- **G0-b — Per-page `@layout` on the public pages.** Add `@layout CmsHomeLayout` to the AuthBlocks
public pages. **Rejected — not possible without forking the RCL:** `Login.razor`/`Register.razor` ship
inside `Cerebellum.AuthBlocks.Web`; we cannot edit them, and there is no host-side override for an RCL
page's `@layout`. G0-a is the only no-fork path.
**DECIDED direction: G0-a** (auth-state-driven `DefaultLayout`), mirroring SkipperHaven. It is the
supported, no-fork way to give the public auth pages the lean layout, it reuses the two layouts
DeepDrftManager already has, and it fixes login's layout at the same time as registration's.
---
## 2c. SkipperHaven — the canonical pattern, and the concrete DeepDrftManager deltas
`SkipperHaven` (`C:\Development\skipper\SkipperHaven\SkipperHaven`) consumes the **same AuthBlocks
library** and already exposes login + register as public/unauthenticated routes with the right layout.
The load-bearing piece is its `Components/Routes.razor`:
- It declares **`[Parameter] AuthenticatedLayout`** (`MainApplicationLayout`) and **`[Parameter]
UnauthenticatedLayout`** (`MainHomeLayout`), takes the **cascaded `Task<AuthenticationState>`**, and in
`OnParametersSetAsync` sets `_currentLayout` to the authenticated layout iff
`authState.User.Identity?.IsAuthenticated == true`, else the unauthenticated layout.
- `AuthorizeRouteView` uses **`DefaultLayout="@_currentLayout"`** (the resolved switch), **not** a static
type. So the AuthBlocks public pages (login, register — both layout-less) render in `MainHomeLayout`
for a signed-out visitor and the app shell once signed in.
- Its `NotAuthorized` renders `RedirectToLogin` for unauthenticated and an inline "not authorized" for
authenticated-but-unprivileged.
- Wiring is otherwise identical to DeepDrftManager: `AuthBlocksWeb.Startup.ConfigureAuthServices(...,
apiBaseUrl)` in `Program.cs`, and `AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly)` on
the mapped components. (Skipper also adds `AuthBlocksWeb.Client` assemblies because it uses the
client-rendered auth surface; **DeepDrftManager is server-rendered `InteractiveServer` and does not need
the `.Client` assembly** — its single `AuthBlocksWeb._Imports` entry is sufficient.)
**Concrete deltas DeepDrftManager needs to match the pattern (this is the whole public-route slice):**
| # | SkipperHaven | DeepDrftManager today | Delta |
|---|--------------|-----------------------|-------|
| D1 | `Routes.razor` resolves `DefaultLayout` from auth state (`AuthenticatedLayout` / `UnauthenticatedLayout`, switched in `OnParametersSetAsync` off the cascaded `AuthenticationState`) | `Routes.razor` uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"` | **Make `DefaultLayout` auth-state-driven**: cascade `Task<AuthenticationState>`, resolve `_currentLayout` = authed ? `CmsLayout` : `CmsHomeLayout`, bind `DefaultLayout="@_currentLayout"`. (G0-a.) **The only required public-route code change.** |
| D2 | Two layouts exist (`MainApplicationLayout`, `MainHomeLayout`) | **Already has both** (`CmsLayout`, `CmsHomeLayout`) | **None** — no new layout to author. DeepDrftManager is ahead of Skipper here. |
| D3 | `ConfigureAuthServices(..., apiBaseUrl)` in `Program.cs` (registers `JwtAuthenticationStateProvider` etc.) | **Already wired** (`Program.cs:35`) | **None.** |
| D4 | AuthBlocks `_Imports` in router `AdditionalAssemblies` + mapped components | **Already wired** (`Routes.razor:2`, `Program.cs:131`) | **None**`/account/register` is already route-reachable. |
| D5 | `NotAuthorized``RedirectToLogin` (unauth) / inline message (authed) | `NotAuthorized``RedirectToLogin` (unauth) / `RedirectToAccessDenied` (authed) | **None functionally** — DeepDrftManager's existing handling is equivalent (it redirects rather than inlines; fine). |
**So the public-registration "track" reduces to a single change: D1 (auth-state-driven `DefaultLayout`).**
Everything else SkipperHaven does is already present in DeepDrftManager. This is why path 2 is no longer
its own host track — it is one router edit, parallelizable with (and smaller than) the admin-nav work.
---
## 3. The genuine remaining work
### G0 — Public-route layout (the path-2 + login fix) — see §2b/§2c
Make `Routes.razor`'s `DefaultLayout` auth-state-driven (G0-a), so the public `/account/login` and
`/account/register` pages render in `CmsHomeLayout` for unauthenticated visitors. Single router change;
no new layout (both already exist); no AuthBlocks-source change. This is **independent** of the admin-nav
work below and can run in parallel.
### G1 — Navigation: there is no way to reach the admin surface from the UI *(the real admin gap)*
`CmsLayout.razor` is an app bar + a single Home `MudIconButton`**no `MudDrawer`, no nav menu.** The
catalogue, releases, upload, and user-admin surfaces are all reachable only by typed URL or in-page
buttons. Mounting `UserAdminMenu` requires a navigation container to mount it *into*.
Three shapes were considered (diverge-before-converge): G1-a app-bar overflow menu (doesn't scale);
**G1-b a real `MudDrawer` nav** mounting the existing CMS destinations + the shipped `UserAdminMenu`
fragment; G1-c a maximal dedicated Administration section with its own dashboard (scope creep for v1).
**DECIDED: G1-b (Daniel, 2026-06-19).** A real `MudDrawer` nav in `CmsLayout` (toggle in the app bar)
holding the existing primary destinations (Catalogue `/catalogue`, Releases `/releases`, Upload
`/tracks/upload`) **and** the shipped `UserAdminMenu` fragment (self-gates to `UserAdmin`+, so it only
shows for admins). Surface **both** admin account paths: path 1 (`SuperRegister`,
`/account/superregister`) and path 3 (via the `UserAdminMenu` Registrations link → its New button). Do
**not** surface the redundant bare `NewUser` (OQ2). It solves the actual gap (no nav) with the least
bespoke code, reuses AuthBlocks' own `MudNavGroup` component verbatim, and gives the CMS the navigation
spine it's missing. G1-c's admin dashboard remains deferred; G1-a is the rejected stopgap.
> **Borrowed precedent:** the standard MudBlazor admin-template layout (persistent left `MudDrawer` +
> `MudNavMenu`/`MudNavGroup`), which `UserAdminMenu` is already authored against. SkipperHaven's
> `MainApplicationLayout`/`NavMenu` is the same shape on the same library — a second confirmation this is
> the idiom, not an invention.
### G2 — Verification pass (the surface is wired but unproven end-to-end)
Because nothing exercised these pages in the CMS, treat first-light as verification, not assumption.
Confirm against a running DeepDrftAPI + Auth DB:
- `/account/register` (**path 2**) renders for an **unauthenticated** visitor in the **lean
`CmsHomeLayout`** (post-G0), pre-fills `UserEmail` + `RegistrationToken` from the query string, and
creates the account (consuming the `pending_registration` row) on submit.
- `/account/login` likewise renders in the lean layout for an unauthenticated visitor (G0 fixes login's
layout as a side benefit).
- `/useradmin/users` lists users (the `UsersClient``api/users/*` round-trip works cross-host with the
bearer token the CMS holds).
- `/account/superregister` (**path 1**) creates a live account immediately — `admin-register` is
`UserAdmin`-gated server-side; the admin's token must carry the role claim end-to-end.
- `/useradmin/registrations/new` (**path 3**) provisions a token **and sends the invite email** — verify
the email arrives (Mailtrap), the link/code are correct, the rollback fires on send failure, and
**critically that the invite link's `{ReturnHost}` points at the CMS origin** so the deep link lands on
the CMS `/account/register` (the place the wrong-host assumption could hide as config — §0 note). This
is the surface most likely to surface a *config* gap (`AuthBlocks:Email:Host`/`:Token` + the return
host in DeepDrftAPI's `environment/authblocks.json`).
- **The full path-3→path-2 loop on one host:** admin provisions in the CMS → email arrives → invited user
opens the deep link → lands on the CMS `/account/register` (lean layout) → redeems → account created.
- `/useradmin/registrations` lists invites; `/useradmin/permissions` reads + assigns roles.
- **CORS / token presentation:** the prior plan widened DeepDrftAPI CORS for the Manager origin for
login; confirm the *same* allowance covers `api/users/*` / `api/pendingregistration/*` / `api/auth/register`
(it should — same origin, same policy).
This pass is where any *latent* break surfaces (a client config typo, a missing role claim, a wrong
return host, a package-version mismatch). Real work even though no code may change if it all passes.
### G3 — Theming / fit-and-finish
The AuthBlocks pages are MudBlazor-default-styled, authored against AuthBlocks' own theme, not the
DeepDrft CMS palette (`DeepDrftPalettes.Cms`). Both `CmsLayout` and `CmsHomeLayout` mount a
`MudThemeProvider` with that palette, so the pages inherit it for free. Scope for v1: **accept
MudBlazor-default styling inside the CMS palette** and only fix outright legibility/contrast breaks
(especially the public `/account/register` + `/account/login` now rendering in `CmsHomeLayout`). A deeper
bespoke restyle of the AuthBlocks grids is explicitly **out of v1** — deferred polish.
### G4 — Package version alignment *(housekeeping, flag don't gate)*
DeepDrftManager references `Cerebellum.AuthBlocks.Web` **10.3.33**; AuthBlocks source is at **10.3.35**.
Minor lag. Bumping is low-risk but **not required** for this phase. Note it; Daniel's call on timing.
---
## 4. Scope boundaries
**In for v1 (one host — `DeepDrftManager` — two parallel tracks):**
*Admin-nav track:*
- G1-b: a `MudDrawer` nav in `CmsLayout` mounting `UserAdminMenu` (+ the existing CMS destinations).
- All three account paths reachable in the CMS: path 1 (`SuperRegister`, provision-now) and path 3
(`/useradmin/registrations/new`, invite-by-email) via nav, plus the users/permissions grids; path 2
(`/account/register`) via the public-route track below.
*Public-route track (the corrected, much smaller path-2 work):*
- G0-a: auth-state-driven `DefaultLayout` in `Routes.razor` so `/account/register` (path 2) **and**
`/account/login` render in the lean `CmsHomeLayout` for unauthenticated visitors. **One router edit**
(§2c D1); both target layouts already exist. The invite email's deep link is the entry point.
*Shared:*
- G2: end-to-end verification of list/create/deactivate users, registrations (incl. the **real invite
email** send + correct return host), permissions, **and the path-3→path-2 loop on one host**.
- G3: accept-the-palette theming; fix only legibility breaks (incl. the public pages in `CmsHomeLayout`).
**Deferred (note, don't build):**
- **Admin dashboard (G1-c)** — a user-admin landing summarizing counts / pending invites. Good later;
not a v1 gate.
- **Reset Password** — the AuthBlocks `Users` page stubs it; **no backing endpoint exists** in
`AuthRoutes`. An *upstream AuthBlocks* gap, not a DeepDrft wiring task. Daniel is handling it as a
**separate AuthBlocks-repo effort** — see the standalone `product-notes/authblocks-password-reset-brief.md`.
**Do not implement password reset inside DeepDrftHome.**
- **Bespoke restyle** of the AuthBlocks grids to the editorial DeepDrft aesthetic.
- A visible public "Register" nav link. Registration is invite-only (the email deep link is the entry
point); a visible Register link with no self-serve code issuance invites confusion/abuse.
**Recommend: no nav link; deep link only.** (Carried over from the dropped OQ9 — still the right call,
now trivially so since the form lives on the CMS host the admin already knows.)
- **G4 version bump** — housekeeping, Daniel's call on timing.
**Explicitly not needed:**
- **Any change to `DeepDrftPublic`.** The corrected host model puts all three paths on the CMS. The public
site is untouched. (This deletes the entire rev-2 cold-start track.)
- Extracting AuthBlocks pages into a new RCL. They ship in `Cerebellum.AuthBlocks.Web`.
- New DI/service wiring, new role seeding, new Auth connection string. All present.
- Editing the AuthBlocks `Login`/`Register` pages to set their layout — impossible without forking the
RCL, and unnecessary (G0-a handles layout host-side).
---
## 5. Phased breakdown (for clean dispatch)
**One host (`DeepDrftManager`), two parallel tracks.** The admin-nav track (19.1) exposes the gated
admin surfaces; the public-route track (19.2) fixes the public auth pages' layout. They touch different
files (`CmsLayout.razor` vs. `Routes.razor`) and are independent — kick both off together. Verification
(19.3) follows both; theming (19.4) follows and is parallel-ok with verification.
- **19.1 — CmsLayout navigation (admin-nav track; the main CMS code wave). DECIDED nav shape: G1-b.**
Add a `MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment
(self-gates to `UserAdmin`+) and the existing CMS destinations (Catalogue `/catalogue`, Releases
`/releases`, Upload `/tracks/upload`). Surface **both** admin account paths: path 1 (`SuperRegister`,
`/account/superregister`) and path 3 (`/useradmin/registrations/new`, via the `UserAdminMenu`
Registrations link → its New button). Do **not** surface the redundant bare `NewUser` (OQ2). Scope:
`CmsLayout.razor` (+ a small `.razor.css` if the drawer needs sizing). **No service, API, data, or
AuthBlocks-source change.**
- Acceptance: an authenticated `Admin` sees a nav drawer; the User Administration group appears and
links to Users / Registrations / Permissions; a "Create user" affordance reaches `SuperRegister`; a
non-`UserAdmin` user does not see the group; existing CMS destinations are reachable from the drawer.
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
`Routes.razor`'s `DefaultLayout` auth-state-driven, mirroring SkipperHaven (§2c D1): cascade
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
`DefaultLayout="@_currentLayout"`. Scope: `DeepDrftManager/Components/Routes.razor` only. **No new
layout (both exist), no package, no service, no AuthBlocks-source change.**
- Acceptance: an **unauthenticated** visitor to `/account/register` sees the form in the lean
`CmsHomeLayout` (not the admin app shell), can pre-fill from the deep link, and self-registers;
`/account/login` likewise renders in the lean layout for an unauthenticated visitor; an authenticated
admin still gets `CmsLayout` for the gated pages.
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise G2 against a running DeepDrftAPI.
Confirm list/create/deactivate users, **invite-email send (path 3) + correct `{ReturnHost}` → CMS
origin**, permission round-trips, cross-host token + CORS, and the **full path-3→path-2 loop on the
single CMS host**. File any latent break as a follow-up (likely a one-line config fix — esp. the
Mailtrap creds + return host — or an upstream AuthBlocks issue). **Mostly test, not code.**
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Walk each user-admin
page in the CMS palette, plus the public `/account/register` + `/account/login` in `CmsHomeLayout`; fix
only contrast/legibility breaks. Defer bespoke restyle.
**Dependency shape:** `{19.1, 19.2} → 19.3`; `19.4` follows `{19.1, 19.2}` and is parallel-ok with
`19.3`. 19.1 and 19.2 are mutually independent (different files) and should kick off together. The
path-3→path-2 acceptance in 19.3 needs 19.1 (to generate an invite) and 19.2 (to land the redeem in the
lean layout); a token minted directly via the API can verify path 2 ahead of 19.1 if needed.
---
## 6. Open questions for Daniel
**Resolved (Daniel, 2026-06-19):**
1. **Nav shape (G1) — DECIDED G1-b.** Real `MudDrawer` nav mounting `UserAdminMenu` + existing CMS
destinations.
2. **Admin create paths — DECIDED: surface path 1 (`SuperRegister`) + path 3 (registration-token form);
do NOT surface the bare `NewUser`.** Both admin paths stay (provision-now vs. invite-by-email — not
duplicates); `NewUser` is redundant with `SuperRegister` and hidden from nav.
5. **Reset Password — DECIDED: non-functional in v1, handled separately** as an upstream AuthBlocks-repo
effort (see `authblocks-password-reset-brief.md`). The 19.3 verification pass must not file it.
6. **Host model — DECIDED (this revision): all three paths on `DeepDrftManager`; NO `DeepDrftPublic`
changes.** Public registration is a public/unauthenticated CMS route exactly like the CMS login. The
public-route work reduces to one router edit (G0-a, §2c D1).
7. **Public-route layout — DECIDED G0-a:** auth-state-driven `DefaultLayout` in `Routes.razor`, mirroring
SkipperHaven; reuses the existing `CmsHomeLayout`. (G0-b — per-page `@layout` — rejected: requires
forking the RCL.)
**Still open:**
3. **Admin dashboard (G1-c) — defer or include?** **Recommend defer.** Net-new surface beyond what
AuthBlocks ships; v1 should expose the working pages, not build a new one.
4. **Package bump (G4) — now or separate?** Bump `Cerebellum.AuthBlocks.Web` 10.3.33 → 10.3.35 in this
pass, or leave it? **Recommend leave it** unless 19.3 surfaces a fix that needs it.
8. **Logged-in admin visiting `/account/register`.** Under G0-a, an authenticated admin who navigates to
the public register page sees it in `CmsLayout` (the app shell) rather than the lean layout. **Recommend
accept** — it is coherent (an admin in a session sees the admin shell) and the page still works; the
primary audience (unauthenticated invitees) gets the lean layout correctly. Flag only if Daniel wants
the register page forced lean regardless of session.
None block 19.1 or 19.2.
@@ -0,0 +1,205 @@
# Team Brief — AuthBlocks: Register `ModelView`'s Missing Dependency via `ConfigureAuthServices`
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
consume AuthBlocks. Everything you need is in this brief or in that one repo (plus a single new BlazorBlocks
package version — see §2, the blocking prerequisite).
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, blocked on a BlazorBlocks publish (see §2). Confirmed at runtime against `Cerebellum.AuthBlocks.Web` 10.3.33 / `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
> This brief is retained as historical record — no further action required.
**This is one half of a two-team, ordered fix. AuthBlocks ships second — see §2 and §9.**
---
## 1. The defect in one sentence
`AuthBlocksWeb` ships `Users.razor` and `Registrations.razor`, both of which render BlazorBlocks'
`<ModelView>` component — but `ConfigureAuthServices` (the single DI entry point consumers call to light up
the AuthBlocks Web surface) does **not** ensure `ModelView`'s required service
`Web.Maintenance.Entities.EditModalSaveContextHolder` is registered. So a consumer that wires up AuthBlocks
the normal way gets an unhandled `InvalidOperationException` that **terminates the Blazor circuit on
navigation** to either page, unless that consumer manually hand-registers an internal BlazorBlocks service
in its own `Program.cs`.
The fix: have `ConfigureAuthServices` call BlazorBlocks' new `AddBlazorBlocksWeb()` extension (which
registers the holder), so AuthBlocks stays self-contained for *its* consumers.
---
## 2. Blocking prerequisite — BlazorBlocks must ship first
This fix **cannot land until BlazorBlocks publishes a new `Cerebellum.BlazorBlocks.Web` version that
exposes a Web-side registration extension** (working name `AddBlazorBlocksWeb()`). That extension is what
actually registers `EditModalSaveContextHolder`; AuthBlocks' job is only to *call* it.
- AuthBlocks is currently on `Cerebellum.BlazorBlocks.Web` **10.3.32**, which has **no** such extension.
- The BlazorBlocks team is shipping the extension and bumping the package as the first half of this fix.
- **Reference the specific new version BlazorBlocks publishes for this fix** — fill in the exact version
number once the BlazorBlocks team reports it. Do not proceed against 10.3.32; the method will not exist.
If you reach this work before the BlazorBlocks version is available, stop and wait for the published version
number. The AuthBlocks change is small once the prerequisite is in hand.
---
## 3. The confirmed failure
### Stack trace (captured from a consuming app on navigation to the Users admin page)
```
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
```
`ModelView` declares `SaveContextHolder` as `required` with `[Inject]`, so Blazor's component factory
throws during component activation. There is no try/catch around component instantiation in the render path,
so the exception propagates and tears down the circuit. The user sees a dead page / "An unhandled error has
occurred" and must reload.
### Which AuthBlocks pages trigger it
`AuthBlocksWeb` ships two user-admin pages that render `<ModelView>`:
- `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor`
- `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor`
Any consumer that routes to either page hits the defect on first navigation.
---
## 4. Why this is AuthBlocks' gap to close (not the consumer's)
`AuthBlocksWeb/Startup.cs` exposes `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)` — the
**single DI entry point** consumers call to light up the AuthBlocks Web surface. It already registers all
the user-admin viewmodels and clients (`UsersViewModel`, `RegistrationsViewModel`, `PermissionsViewModel`,
their clients, auth state, hierarchical authorization, etc.). It does **not** register
`EditModalSaveContextHolder` and does **not** call any BlazorBlocks Web extension.
So AuthBlocks ships pages that depend on `ModelView` while leaving one of `ModelView`'s required services
unregistered. The consumer is silently expected to fill the gap — and that is exactly what happened:
**two independent downstream products** each had to hand-register the internal BlazorBlocks service
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
AuthBlocks' shipped pages work. The whole value of a single `ConfigureAuthServices` entry point is that a
consumer calling it (plus the already-required `AddMudServices`) gets a working surface with zero manual
registrations. Today they don't. Closing this gap inside `ConfigureAuthServices` restores that promise.
Note: `IDialogService` / `ISnackbar` (also injected by `ModelView`) come from MudBlazor's
`AddMudServices()`, which every AuthBlocks Web consumer already calls as a documented prerequisite — those
are not the gap. The single library-owned gap is `EditModalSaveContextHolder`.
---
## 5. The fix
### 5.1 Bump the BlazorBlocks reference
Update the `Cerebellum.BlazorBlocks.Web` package reference (currently 10.3.32) to the new version
BlazorBlocks publishes for this fix (see §2 — fill in the exact version). This is what makes
`AddBlazorBlocksWeb()` available.
### 5.2 Call the extension from `ConfigureAuthServices`
```csharp
// AuthBlocksWeb/Startup.cs, inside ConfigureAuthServices(...)
services.AddBlazorBlocksWeb(); // registers EditModalSaveContextHolder for the ModelView-based pages
```
Order does not matter for this scoped service; place it near the other registrations. The
`AddBlazorBlocksWeb()` extension registers `EditModalSaveContextHolder` as scoped per circuit (its correct
lifetime — the holder is per-circuit mutable state that `ModelView` writes and `EditModelModal` reads).
### 5.3 Why this belongs in `ConfigureAuthServices`, not pushed onto consumers
- `ConfigureAuthServices` is AuthBlocks' **single DI entry point**. A consumer calling only
`AddMudServices()` + `ConfigureAuthServices(...)` should get a fully working user-admin surface. Folding
the BlazorBlocks call into the existing entry point keeps that promise; introducing a second method the
consumer must remember to call (or expecting them to call `AddBlazorBlocksWeb()` themselves) just relocates
the leaked registration one layer up.
- AuthBlocks must **not** register `EditModalSaveContextHolder` directly (reaching into BlazorBlocks'
internal `Web.Maintenance.Entities` namespace) — that is the same smell the two consumers exhibited,
merely relocated. Compose BlazorBlocks' own `Add*` extension instead; the registration lives with its
owner, and AuthBlocks stays self-contained by calling it. This is the standard ASP.NET Core layering:
each library exposes an `Add*` for its own services, and a higher-level library's `Add*` calls the lower
one's.
---
## 6. Constraints
- **Do not register `EditModalSaveContextHolder` directly** in AuthBlocks. Call `AddBlazorBlocksWeb()`;
let BlazorBlocks own its type (§5.3).
- **Keep `ConfigureAuthServices` the single AuthBlocks entry point.** Do not introduce a second method
consumers must remember to call; fold the BlazorBlocks call into the existing one.
- **MudBlazor remains a caller-owned prerequisite.** AuthBlocks already relies on consumers calling
`AddMudServices()` for all its MudBlazor-based pages; do not absorb it into `ConfigureAuthServices`.
- **Versioning:** AuthBlocks Web packs/pushes via its `pack.ps1` / packaging script. `AuthBlocksWeb` is
currently `Cerebellum.AuthBlocks.Web` **10.3.33**; bump to the next version after referencing the new
BlazorBlocks version and adding the `AddBlazorBlocksWeb()` call. **Record the new version** so consumers
can pin.
---
## 7. Acceptance criteria
1. The `Cerebellum.BlazorBlocks.Web` package reference is bumped to the new version BlazorBlocks published
for this fix, and `ConfigureAuthServices` calls `AddBlazorBlocksWeb()`.
2. A fresh consumer that calls **only** `AddMudServices()` and
`AuthBlocksWeb.Startup.ConfigureAuthServices(...)` — and **registers nothing else by hand** — can
navigate to the Users admin page and the Registrations admin page with **no `InvalidOperationException`**
and **no circuit teardown**.
3. Working behavior means not just page load: opening the edit dialog on a user, saving a valid change,
**succeeds** — i.e. the save bridge actually works end-to-end through AuthBlocks' pages.
4. No AuthBlocks consumer needs to touch `Web.Maintenance.Entities` directly; no manual registrations are
required beyond the documented `AddMudServices`.
5. `AuthBlocksWeb` is published as a version bump from 10.3.33, and the new version number is recorded.
---
## 8. Open questions for the implementing team / its sponsor
1. **Exact BlazorBlocks version to reference.** Pending the BlazorBlocks team's publish (§2). Confirm the
published version number and the exact extension method name (proposed `AddBlazorBlocksWeb()`) before
landing the call.
2. **Placement within `ConfigureAuthServices`.** Anywhere in the method works (scoped service, order-
independent). Confirm there is no existing convention in `Startup.cs` for grouping third-party `Add*`
calls that this should follow.
3. **Any other AuthBlocks pages built on `ModelView`/`NewModelView`?** This brief identified Users and
Registrations. If the team expects to add more maintenance pages, note that calling `AddBlazorBlocksWeb()`
once covers them all (it is the single home for the maintenance-component deps).
---
## 9. Suggested reading order in the repo
1. `AuthBlocksWeb/Startup.cs``ConfigureAuthServices`, the single entry point; add the
`AddBlazorBlocksWeb()` call here.
2. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — renders `<ModelView>`; triggers the
defect on navigation.
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor` — the second page that
renders `<ModelView>`.
4. The AuthBlocks Web `.csproj` — the `Cerebellum.BlazorBlocks.Web` package reference to bump.
5. The AuthBlocks `pack.ps1` / packaging script — to bump and publish `AuthBlocksWeb` after referencing the
new BlazorBlocks version.
---
## 10. Cross-team ordering (important — you ship second)
This fix is layered across two repos and **must land in order**:
1. **BlazorBlocks ships first.** It adds `AddBlazorBlocksWeb()`, bumps `Cerebellum.BlazorBlocks.Web` from
10.3.32, packs/pushes, and reports the new version number.
2. **Then AuthBlocks (this team)** bumps its `Cerebellum.BlazorBlocks.Web` reference to that published
version, calls `AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bumps `Cerebellum.AuthBlocks.Web` from
10.3.33, and publishes.
This team **cannot complete its part until the BlazorBlocks version is published** (§2). Confirm that
version number is in hand before starting.
@@ -0,0 +1,277 @@
# Team Brief — AuthBlocks: Normalize the Account-Creation Pages (NewUser vs. Registration vs. SuperRegister)
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
consume AuthBlocks. Everything you need is in this brief or in that one repo.
**Status:** scoped request, **decisions approved 2026-06-20** — ready for implementation. Author: product-designer (for a downstream consumer team).
Date: 2026-06-20.
---
## 1. The problem in one sentence
AuthBlocks ships **three** account-creation pages whose identities have drifted: the **New User** page
(`/useradmin/users/new`) is broken and has become a half-built *duplicate of the invite/registration flow*
instead of the **direct admin-provisioning** path it is named for — while a separate page,
**SuperRegister** (`/account/superregister`), is the page that actually performs direct admin provisioning.
The two "create an account now" identities live in different places, one of them is broken, and the labels
lie about what each does. This brief normalizes the three paths into distinct, correctly-named flows.
The intended end state (per the consuming team):
1. **Direct provision** — admin creates a *live* account immediately (username + email + password + roles),
bypassing email entirely. This is what **New User** should be.
2. **Admin invite-by-email** — admin sends a registration code + link to an email; the recipient redeems it
to create their own account. This is the **Registration** flow.
3. **Public self-service redeem** — the recipient lands on the public **Register** page and completes
account creation with the code. (Already correct; included for completeness.)
---
## 2. Current-state analysis (read this before designing — it is the crux)
### 2.1 New User — `/useradmin/users/new` (BROKEN + mislabeled + duplicate)
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUser.razor` — just renders `<NewUserForm/>`.
- **Form markup:** `.../Users/NewUserForm.razor`. The card header reads **"Activate New User"**. The body
text reads:
> *"Create a new user account, bypassing email registration. The password must be provided now."*
This message describes **direct provisioning** — and it is the **wrong message for what the page actually
does**, because:
- The form has **only an Email field**. There is **no username field, no password field, and no role
selector** — despite the copy promising "the password must be provided now."
- The submit button is labeled **"Send Registration Code"** — i.e. invite-flow language, contradicting the
"bypassing email registration" body copy directly above it.
- **What it actually does:** **nothing — it throws.** The form's `OnValidSubmit` is wired to a stub:
```csharp
// NewUserForm.razor.cs
private void X()
{
throw new NotImplementedException();
}
```
The code-behind also carries a **commented-out body** that, if enabled, would call
`Client.CreatePendingRegistration(...)` and pop a `UserSubmittedModal` — i.e. it would make NewUser
**identical to the invite/Registration flow**. So the page is mid-migration: someone started turning the
direct-provision page into a second copy of the invite page, didn't finish, and left a throwing stub.
- **Backing model is the invite model, not the provision model:** `NewUserForm` binds
`PendingRegistrationInputModel Input` and injects `PendingRegistrationClient` — the *registration* model
and client, **not** `AdminRegisterRequest` / the admin-register path. This is the concrete duplication:
NewUser is plumbed for invites, not for direct provisioning.
**In short:** NewUser is named for direct provisioning, *says* it does direct provisioning ("bypassing email
registration… password must be provided now"), is *wired* for invites (PendingRegistration model/client +
commented-out invite call), is *labeled* for invites ("Send Registration Code"), and **actually throws
`NotImplementedException`**. Every layer disagrees with every other layer.
### 2.2 SuperRegister — `/account/superregister` (the REAL direct-provision page, working)
- **Page:** `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor`. `[HierarchicalRoleAuthorize(UserAdmin)]`,
`@rendermode InteractiveServer`. Title "Admin Register"; header "Create a new account."
- **This is the genuine direct-provision UI.** Full form: Username, Email, Password, Confirm Password, and a
**multi-select Roles** dropdown populated from `AuthApiClient.GetRolesAsync`.
- **Backing model + call:** binds `AdminRegisterRequest` (UserName, Email, Password, ConfirmPassword,
RoleIds) and calls `AuthApiClient.AdminRegisterAsync(Input, token)``POST api/auth/admin-register`.
- **What that endpoint does** (`AuthBlocksLib/Routes/AuthRoutes.cs`, `AdminRegister`, role-gated to
`UserAdmin`): rejects a duplicate email; resolves each `RoleId` to a role name up front (fail-fast);
creates the user via `userService.Add(user, request.Password)` with `EmailConfirmed = true`; assigns
roles (deleting the half-created user if a role assignment fails); returns an `AuthResponse`. **No email,
no token, no pending row — a live account immediately.** This is exactly the behavior the consumer wants
*New User* to have.
### 2.3 New Registration — `/useradmin/registrations/new` (the invite flow, working)
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistration.razor``<NewRegistrationForm/>`.
- **Form:** `.../Registrations/NewRegistrationForm.razor`. Header **"Provision New User"** (note: this label
is *also* slightly off — it performs an *invite*, not a provision; see decisions §5). Body: *"A registration
code and link will be sent to the email address provided."* Fields: Email + multi-select Roles. Button:
"Send Registration Code".
- **Backing model + call:** binds `PendingRegistrationInputModel`, injects `PendingRegistrationClient`, and
on submit calls `Client.CreatePendingRegistration(email, roles, returnHost=.../account/register)`, then
shows `RegistrationSubmittedModal` (which reports "An email has been sent to: …").
- **What that hits:** `POST api/pendingregistration/create` (`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs`,
`Create`, group role-gated to `UserAdmin`): rejects existing user / existing pending registration;
generates a registration token; persists a `PendingRegistration` row with the token **hash** and an expiry;
emails a code + a deep link (`returnHost?UserEmail=&RegistrationToken=`) via `IGeneralEmailSender`. The
recipient later redeems at the public **Register** page (`POST api/auth/register`, which validates the
code, creates the user, consumes the token).
### 2.4 The duplication, stated precisely
`NewUserForm` and `NewRegistrationForm` **bind the same model (`PendingRegistrationInputModel`) and the same
client (`PendingRegistrationClient`), and NewUser's commented-out handler is a near-copy of
NewRegistration's `CreatePendingRegistration` handler.** NewUser is, in its half-built state, a strictly
worse duplicate of NewRegistration (no roles field, throwing stub) — while the page that *should* own
NewUser's intended behavior (direct provision) is the separate `SuperRegister`. The system has:
- **two pages aimed at the invite flow** (Registration = working; NewUser = broken duplicate), and
- **one page doing direct provision under a different name/route** (SuperRegister),
- **zero working pages at the New User route doing what "New User" implies.**
### 2.5 The API is already correct — this is a Web-layer normalization
Both endpoints exist and work today: `admin-register` (direct provision, role-gated) and
`pendingregistration/create` (invite). **No new API endpoints are required.** This task is about pointing
the right *page* at the right *existing* endpoint, with honest labels and routes. (Contrast the
password-reset brief, which was build-from-scratch on both tiers.)
---
## 3. The normalization design
End state: **three crisp paths, each at one page, each correctly labeled, each on the right endpoint.**
| Path | Page (canonical) | Route | Endpoint | Result |
|---|---|---|---|---|
| Direct provision | **New User** | `/useradmin/users/new` | `POST api/auth/admin-register` | live account now |
| Admin invite | **New Registration** | `/useradmin/registrations/new` | `POST api/pendingregistration/create` | emailed code + link |
| Public redeem | **Register** | `/account/register` | `POST api/auth/register` | recipient self-creates |
The core move: **make `NewUser` the canonical direct-provision page by absorbing SuperRegister's behavior**,
fix its copy, and resolve the now-redundant SuperRegister. Registration stays as-is (modulo a label tidy).
### 3.1 Recommended approach — "Absorb into NewUser, retire SuperRegister"
1. **Rebuild `NewUserForm` as the direct-provision form.** Re-bind it from `PendingRegistrationInputModel` /
`PendingRegistrationClient` to **`AdminRegisterRequest`** and the **auth client** that calls
`AdminRegisterAsync` (the `IAuthApiClient` + `IAuthSession` token pattern SuperRegister already uses).
Bring across SuperRegister's full field set: Username, Email, Password, Confirm Password, and the
role multi-select sourced from `GetRolesAsync`. Delete the throwing `X()` stub and the commented-out
invite handler. Keep the existing card/`MudContainer` chrome so it matches the other UserAdmin pages
(NewUser/NewRegistration share a card layout that the standalone SuperRegister does not).
2. **Fix the copy.** Header → e.g. **"New User — Direct Provision"** (or "Activate New User", kept, now that
the page genuinely activates one). Body → keep the accurate *"Create a live account now, bypassing email
registration. Set the password directly."* Button → **"Create Account"** (retire the misleading
"Send Registration Code" on this page). On success, show a confirmation and route back to
`/useradmin/users` (force-reload so the grid refreshes — mirror NewRegistration's post-submit nav).
3. **Retire `SuperRegister`.** Once NewUser owns direct provision, SuperRegister is a duplicate. Preferred:
**delete the page and redirect `/account/superregister` → `/useradmin/users/new`** so any existing
bookmark or consumer nav link doesn't 404 during the consumer's catch-up window (see §7). Keep the
redirect lightweight (a `NavigationManager.NavigateTo` in a thin page, or a server redirect). Do **not**
leave two separate-but-identical "create now" pages alive.
4. **Tidy Registration's label** (small, optional but recommended for the normalization to be coherent):
the invite page header currently says **"Provision New User"**, which collides with the direct-provision
concept now owned by NewUser. Rename it to **"Invite New User"** / **"New Registration"** so "provision"
unambiguously means *direct* and "invite/registration" means *emailed code*. No behavior change.
**Why this approach:** NewUser's route (`/useradmin/users/new`) is where an admin looking at the Users grid
expects to click "add a user," and it lives in the UserAdmin/Users area beside the grid — the natural home
for the canonical create-now action. SuperRegister sits oddly under `/account/*` (the *public* auth area,
alongside Login/Register) despite being an admin-only action; folding it into UserAdmin/Users fixes that
mis-placement as a side effect. The API already supports it, so this is low-risk re-pointing, not new
behavior.
### 3.2 Alternatives considered
- **B — Keep SuperRegister canonical; make NewUser a redirect to it.** Inverse of the recommendation:
delete `NewUserForm`'s logic, point `/useradmin/users/new``/account/superregister`. Cheaper (no form
rebuild), but it **enshrines the mis-placement** (admin-only page under `/account/*`) and leaves the
visual inconsistency (SuperRegister doesn't use the UserAdmin card chrome). Rejected: it normalizes the
*names* but not the *information architecture*. Choose this only if rebuilding the form is deemed
out-of-budget for now — and even then, treat it as interim.
- **C — Keep both pages, share one form component.** Extract a single `DirectProvisionForm` component and
render it from both `NewUser.razor` and `SuperRegister.razor`. Eliminates code duplication but **leaves
two routes for one action** — exactly the "two create-now pages" the consumer is asking to remove. Rejected
for the explicit goal; the duplication the consumer dislikes is at the *page/route* level, not just code.
- **D — Make NewUser a *chooser*** (two buttons: "Create now" vs. "Invite by email").** A single entry point
that branches to the two real flows. Genuinely nice UX and worth noting as a *future* enhancement, but it
is scope-creep on a "normalize what exists" request and introduces a fourth surface. Defer.
**Recommendation: A.** It produces the cleanest end state (correct names, correct routes, correct IA, no
redundant page) at the cost of one form rebuild that is mostly a copy of SuperRegister's already-working
form.
---
## 4. Constraints
- **No new API endpoints.** `admin-register` and `pendingregistration/create` already exist and are correct
(§2.5). If you find yourself adding an endpoint, stop — you've taken a wrong turn.
- **Preserve role-gating.** Direct provision must stay `[HierarchicalRoleAuthorize(UserAdmin)]` (SuperRegister
has it; ensure rebuilt NewUser keeps it — NewUser currently inherits whatever the UserAdmin pages set, so
verify the attribute is present on the page).
- **Reuse the existing client + session pattern.** Direct provision uses `IAuthApiClient.AdminRegisterAsync`
+ `IAuthSession.GetValidTokenAsync` (as SuperRegister does). Do not introduce a new client; do not route
direct provision through `PendingRegistrationClient`.
- **Match the UserAdmin page conventions.** NewUser/NewRegistration use a `MudContainer` + `MudCard` +
back-button layout; keep the rebuilt NewUser in that house style rather than transplanting SuperRegister's
bare `MudGrid` layout verbatim.
- **No 404s for retired routes.** If SuperRegister is removed, `/account/superregister` must redirect, not
break (§3.1.3, §7).
- **Versioning:** lands as a normal AuthBlocks Web version bump, packed/pushed via `pack.ps1`. `AuthBlocksWeb`
is currently `Cerebellum.AuthBlocks.Web` **10.3.36**; bump to **10.3.37** (or the next free patch if
another bump has landed since this brief). **Record the published version** so the consumer can pin.
---
## 5. Decisions for the sponsor (Daniel) — resolved 2026-06-20
1. **Which page is canonical for direct provision?** **DECISION (2026-06-20): New User** (`/useradmin/users/new`)
is the canonical direct-provision page, absorbing SuperRegister (§3.1). ✅ approved.
2. **What happens to SuperRegister?** **DECISION (2026-06-20): delete + redirect** `/account/superregister`
`/useradmin/users/new`. ✅ approved.
3. **Route naming.** **DECISION (2026-06-20):** keep `/useradmin/users/new` as the canonical direct-provision
route. No `/account/*` route retained. ✅ approved.
4. **Copy/wording on NewUser.** **DECISION (2026-06-20):** go with the recommended strings in §3.1.2 — header
"New User — Direct Provision" or "Activate New User"; button "Create Account"; accurate direct-provision body
copy. Implementer discretion within that intent. ✅ approved.
5. **Tidy the Registration label** ("Provision New User" → "Invite New User"/"New Registration")?
**DECISION (2026-06-20):** yes, in scope. ✅ approved.
6. **Future chooser page (Alternative D)****DECISION (2026-06-20):** deferred; captured as a possible later
enhancement. ✅ approved (defer).
---
## 6. Acceptance criteria
1. Navigating to `/useradmin/users/new` shows a **direct-provision form** with Username, Email, Password,
Confirm Password, and a Roles multi-select — **no "Send Registration Code" language**, no throwing stub.
2. Submitting that form with a valid username/email/password (and optional roles) calls
`POST api/auth/admin-register`, creates a **live account immediately** (no email sent, no pending-
registration row), assigns the selected roles, shows a success confirmation, and returns to
`/useradmin/users` with the new user visible in the grid.
3. The page's copy accurately describes direct provisioning; the button reads "Create Account" (or the
sponsor-approved string).
4. `NewUserForm` no longer binds `PendingRegistrationInputModel` / `PendingRegistrationClient`, no longer
contains the `X()` stub or the commented-out invite handler.
5. SuperRegister is resolved per the sponsor's decision: if retired, `/account/superregister` **redirects**
to `/useradmin/users/new` (no 404, no second working create-now page); if kept as alias, it is explicitly
an alias, not an independent duplicate.
6. The invite flow at `/useradmin/registrations/new` still works unchanged (emails a code + link); if its
label was tidied, the change is cosmetic only.
7. Direct provision remains `UserAdmin`-role-gated; an unauthorized user cannot reach it.
8. Published as a version bump from 10.3.36 (expected **10.3.37**); the new version number is recorded.
---
## 7. Downstream consequence (for the consumer to handle later — NOT this team's work)
The consuming product (DeepDrftManager) currently surfaces **SuperRegister (`/account/superregister`)** in
its CMS navigation as the **"Provision User"** entry, alongside a **Registrations** link. If this
normalization changes which page is canonical or its route — specifically if `/account/superregister` is
retired in favor of `/useradmin/users/new` — the consumer's nav link will need a small follow-up update
**after this AuthBlocks change ships and the consumer bumps its package reference**. The recommended
delete-**and-redirect** (§3.1.3, decision §5.2) is precisely to keep that consumer working in the interval
between this ship and the consumer's catch-up. **This is noted only so the implementing team understands why
the redirect matters; updating the consumer's nav is out of scope for AuthBlocks.**
---
## 8. Suggested reading order in the repo
1. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUserForm.razor` + `.razor.cs` — the broken page; the
wrong message, the missing fields, the `X()` stub, the commented-out invite handler. **Start here.**
2. `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor` — the working direct-provision form to
absorb (fields, role multi-select, `AdminRegisterAsync` call, `IAuthSession` token pattern).
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistrationForm.razor` + `.razor.cs` — the
invite flow NewUser was wrongly duplicating; the post-submit modal + force-reload nav pattern to mirror.
4. `AuthBlocksLib/Routes/AuthRoutes.cs` — the `AdminRegister` endpoint (direct provision; what NewUser must
call) and `Register` (public redeem); the `ApiResult`/result conventions.
5. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the `Create` endpoint (invite); confirms the two
endpoints are already distinct and correct (no API work needed).
6. `AuthBlocksModels/ApiModels/AuthModels.cs``AdminRegisterRequest` (UserName, Email, Password,
ConfirmPassword, RoleIds) is the model NewUser should bind.
7. `AuthBlocksWeb/ApiClients/IAuthApiClient.cs` / `AuthApiClient.cs``AdminRegisterAsync` + `GetRolesAsync`.
8. `AuthBlocksWeb/AuthBlocksWeb.csproj``<Version>10.3.36</Version>` to bump.
9. `pack.ps1` — pack/push after the bump; record the published version.
@@ -0,0 +1,258 @@
# Team Brief — BlazorBlocks: Register the Missing `EditModalSaveContextHolder` DI Dependency
**Audience:** an orchestrator (and its implementers) working **only** in the BlazorBlocks repository at
`C:\Development\BlazorBlocks`. You do not need, and should not assume, any knowledge of AuthBlocks or of
any product that consumes BlazorBlocks. Everything you need is in this brief or in that one repo.
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, not yet started. Confirmed at runtime against `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
> This brief is retained as historical record — no further action required.
**This is one half of a two-team, ordered fix. BlazorBlocks ships first — see §9.**
---
## 1. The defect in one sentence
The BlazorBlocks `ModelView` component (and the `EditModelModal` it drives) has a `required [Inject]`
dependency on `Web.Maintenance.Entities.EditModalSaveContextHolder`, but **the `Web` package ships no
`IServiceCollection` registration extension at all** — so the dependency is never registered by the
library, and any consumer that surfaces a page built on `ModelView` gets an unhandled
`InvalidOperationException` that **terminates the Blazor circuit on navigation**, unless that consumer
manually hand-registers the internal service in its own `Program.cs`.
The fix is a BlazorBlocks library fix delivered as a version bump: add a Web-side `Add*` extension that
registers the holder.
---
## 2. The confirmed failure (downstream symptom — evidence, not your concern to chase)
The defect was discovered by a consumer navigating to an admin page built on `ModelView`. The captured
stack trace is included here as confirming evidence of the unregistered-service failure mode; the
consumer's identity and its pages are out of scope for this team — your job is to register the dependency
the library requires.
```
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
```
Because `SaveContextHolder` is declared `required` with `[Inject]`, Blazor's component factory throws
during component activation. There is no try/catch around component instantiation in the render path, so
the exception propagates and tears down the circuit. The end user sees a dead page / "An unhandled error
has occurred" and must reload.
The tell that this is a library gap and not a consumer mistake: **two independent downstream products**
have each had to hand-register the *same* internal BlazorBlocks service
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
`ModelView`-based pages work. When two unrelated consumers independently discover they must reach into a
library's internal namespace (`Web.Maintenance.Entities`) and register a type the library never documented
as a consumer responsibility, that is a leaked registration. The correct owner of the registration is the
library — this team.
---
## 3. Root-cause findings (from reading the source)
### 3.1 The service and who needs it
`EditModalSaveContextHolder` (`C:\Development\BlazorBlocks\Web\Maintenance\Entities\EditModalSaveContextHolder.cs`)
is a tiny per-circuit slot:
```csharp
public sealed class EditModalSaveContextHolder
{
public IEditModalSaveContext? Current { get; set; }
}
```
It is injected as `required` in **two** library components:
- `Web/Maintenance/Entities/ModelView.razor.cs:42` — sets `SaveContextHolder.Current` before opening the
edit dialog and clears it in a `finally` (see `EditItem`, lines 137172).
- `Web/Maintenance/Entities/EditModelModal.razor:40` — reads `SaveContextHolder.Current` to obtain the
typed save callback (`SaveContext`).
The holder is deliberately the **per-circuit bridge** between these two components — it threads a save
closure from the page-side `ModelView` into the generic `EditModelModal` without forcing a parameter
through every per-page modal wrapper. So **both** components fail without it; the bug surfaces at
`ModelView` activation simply because that component is constructed first.
### 3.2 Is there already a Web-side registration extension to fix?
**No Web-side registration extension exists.** A full scan of BlazorBlocks for `IServiceCollection`
extension methods finds only:
- `Data.Postgres/ServiceCollectionExtensions.cs``AddBlazorBlocksPostgres()` (registers
`IDbExceptionClassifier`; a data-layer concern, unrelated to the Web components).
- `API/Errors/...AddResultMessagePolymorphism(...)` (a JSON resolver helper, not DI).
There is **no** `AddBlazorBlocks()` / `AddBlazorBlocksWeb()` / `AddMaintenance()` method in the `Web`
project (`Cerebellum.BlazorBlocks.Web`). So `EditModalSaveContextHolder` is not "missing from an existing
extension" — there is no Web-side extension at all. The library ships components with a hard DI dependency
and provides no entry point to register that dependency.
### 3.3 Is `EditModalSaveContextHolder` the *only* missing dependency?
**It is the only library-owned missing registration.** Every `[Inject]` across the BlazorBlocks
`Web/Maintenance` tree was enumerated:
| Component | Injected types |
|---|---|
| `ModelView<...>` | `TViewModel` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
| `EditModelModal<TModel>` | `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
| `NewModelView<...>` | `TClient` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor) |
Everything else resolves through services consumers already register:
- `NavigationManager` — Blazor framework.
- `IDialogService`, `ISnackbar` — MudBlazor, registered by the consumer's `AddMudServices()` (a documented
MudBlazor prerequisite).
- `TViewModel` / `TClient` — the per-entity viewmodel/client, registered by the consumer (or by a
higher-level library) for its own entities.
So `EditModalSaveContextHolder` is the single library-owned gap. Registering the one holder closes the
whole set, provided the consumer has already called `AddMudServices()`.
---
## 4. The fix
Add a new `IServiceCollection` extension in the `Web` project that registers the maintenance components'
library-owned dependencies. This is where `EditModalSaveContextHolder` belongs — it is BlazorBlocks'
internal bridge, and any product using `ModelView` needs it.
```csharp
// C:\Development\BlazorBlocks\Web\ServiceCollectionExtensions.cs (new)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; // for TryAddScoped
using Web.Maintenance.Entities;
namespace Web;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the services required by the BlazorBlocks maintenance UI
/// components (ModelView / EditModelModal / NewModelView). Call this in
/// your application's DI setup when using those components.
/// Note: MudBlazor (AddMudServices) is a separate, caller-owned prerequisite
/// and is intentionally NOT registered here.
/// </summary>
public static IServiceCollection AddBlazorBlocksWeb(this IServiceCollection services)
{
// Per-circuit slot bridging ModelView's save callback into EditModelModal.
// Scoped per circuit; see lifetime rationale. TryAddScoped => idempotent.
services.TryAddScoped<EditModalSaveContextHolder>();
return services;
}
}
```
The method should be the single place the maintenance components' library-owned deps are registered, so
any future `ModelView` dependency is added here once and every consumer picks it up on the next bump.
### Lifetime rationale — scoped, not singleton, not transient
The extension must register the holder as **scoped** (`AddScoped` / `TryAddScoped`):
- **Why not singleton:** the holder is per-circuit mutable state — `ModelView.EditItem` writes `Current`
immediately before opening the dialog and nulls it in `finally`; `EditModelModal` reads it. A singleton
would share one slot across all users/circuits and cross-contaminate concurrent edits — a
correctness/security bug.
- **Why not transient:** a transient would hand `ModelView` and `EditModelModal` *different* instances, so
the modal would never see the context the view set. The bridge would silently no-op and edits would fall
back to the legacy "close with model" path (the `SaveContext is null` branch in `EditModelModal.Submit`).
This is the dangerous failure mode: the page loads fine, but saves quietly take the wrong path.
- The type's own XML doc states it is "Scoped per circuit." `AddScoped` in Blazor Server = one instance
per circuit, which is exactly the intended semantics.
Document the scoped requirement in the extension's summary so it is not "tidied" to singleton later.
---
## 5. Constraints
- **Use scoped** for `EditModalSaveContextHolder` — not singleton, not transient (§4).
- **Do not change `ModelView` / `EditModelModal` to drop the `required`/`[Inject]`** — the holder is the
intentional design (the bridge described in §3.1, and in the type's own doc comment). The fix is to
*register* the dependency, not to remove it.
- **MudBlazor remains a caller-owned prerequisite.** `AddBlazorBlocksWeb` must **not** call
`AddMudServices()` — consumers configure MudBlazor (theme, snackbar options) themselves, and
double-registration would clobber their config. Document `AddMudServices` as a prerequisite in the
extension's summary; do not absorb it.
- **Versioning:** the `Web` package packs/pushes via `C:\Development\BlazorBlocks\pack.ps1`. `Web` is
currently `Cerebellum.BlazorBlocks.Web` **10.3.32**; bump to the next version and pack/push. **Record the
new version number** — the AuthBlocks team needs it to reference (see §9).
---
## 6. Acceptance criteria
1. The `Web` project exposes a new `IServiceCollection` extension (`AddBlazorBlocksWeb()` or the confirmed
house name) that registers `EditModalSaveContextHolder` as **scoped**, with a summary documenting the
scoped requirement and the `AddMudServices` prerequisite.
2. A product that uses BlazorBlocks `ModelView` (with or without any higher-level library) can register
the maintenance deps via a single call to the new extension and gets working behavior.
3. Working behavior means not just page load: opening an edit dialog and **saving a valid change
succeeds** — i.e. the save bridge actually works (the holder is scoped correctly so `EditModelModal`
sees the context `ModelView` set). A page that loads but silently no-ops the save (the transient-lifetime
trap in §4) does **not** pass.
4. The `Web` package is published as a version bump from 10.3.32, and the new version number is recorded
for the AuthBlocks team.
---
## 7. Open questions for the implementing team / its sponsor
1. **Extension method name.** `AddBlazorBlocksWeb()` is proposed for consistency with the existing
`AddBlazorBlocksPostgres()`. Confirm, or pick the preferred house name (e.g.
`AddBlazorBlocksMaintenance()` if the team expects to split Web concerns further). *Recommendation:
`AddBlazorBlocksWeb()`.*
2. **Idempotency.** Use `TryAddScoped` (recommended) so a consumer that calls the extension directly *and*
via a higher-level library's setup gets a no-op on the second call rather than a duplicate registration.
3. **Scope of the extension.** Register only `EditModalSaveContextHolder` now (the only current gap), or
pre-emptively make `AddBlazorBlocksWeb()` the home for *all* future maintenance-component deps?
*Recommendation: ship it with just the holder now, but frame it (name + doc) as the general home so
future deps land in one place — no speculative registrations.*
4. **Other unregistered library-owned `[Inject]` deps elsewhere?** This brief scoped the audit to the
`Web/Maintenance` tree. If the team wants a clean bill of health, grep the whole `Web` project for
`[Inject]` of BlazorBlocks-owned types and fold any others into the same extension. *Recommendation: do
the quick full-project grep while you are in here; cheap insurance.*
---
## 8. Suggested reading order in the repo
1. `Web/Maintenance/Entities/EditModalSaveContextHolder.cs` — the unregistered service (and its scoped doc).
2. `Web/Maintenance/Entities/ModelView.razor.cs``[Inject]` at line 42; `EditItem` (137172) shows the
holder being written/cleared around the dialog.
3. `Web/Maintenance/Entities/EditModelModal.razor``[Inject]` at line 40; `Submit` shows the holder being
read (and the `SaveContext is null` fallback that masks the bug into a wrong-behavior path if the
lifetime is botched).
4. `Data.Postgres/ServiceCollectionExtensions.cs` — the existing `Add*` convention to mirror.
5. `Web/Web.csproj` — package id `Cerebellum.BlazorBlocks.Web`, current version (10.3.32), where to add the
new `ServiceCollectionExtensions.cs`.
6. `pack.ps1` — the pack/push flow for the version bump.
---
## 9. Cross-team ordering (important — you ship first)
This fix is layered across two repos and **must land in order**:
1. **BlazorBlocks (this team) ships first.** Add `AddBlazorBlocksWeb()`, bump `Cerebellum.BlazorBlocks.Web`
from 10.3.32, pack/push, and **report the new version number**.
2. **Then AuthBlocks** bumps its `Cerebellum.BlazorBlocks.Web` reference to the version this team just
published and calls `AddBlazorBlocksWeb()` from its own setup entry point.
The AuthBlocks team **cannot complete its part until this team's new version is published**. This team is
not blocked by anyone — just ship the extension, bump the version, and hand off the new version number.
You do not need to touch or know anything about AuthBlocks.
@@ -0,0 +1,243 @@
# Theme / Dark-Mode Remediation — DRY token pass
Status: proposed. Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).
A design analysis of the DeepDrft theme system, focused on the dark theme, with a DRY
remediation plan that resolves a punch-list of six reported symptoms through **shared
theme tokens** rather than per-component patches. Daniel reported the symptoms; this note
maps the architecture, isolates the root causes, and sequences the fix.
Prior art this borrows from: `product-notes/track-card-theming.md` (landed 2026-06-05) —
the same class of problem (theme-aware recolor under `.deepdrft-theme-dark`, legible in
both palettes) solved once already with the same mechanism. This note generalizes that
fix from one component to the recurring pattern behind it.
---
## 1. How the theme system is wired today (the map)
There are **three** colour layers, and the bugs all live in how the third one bypasses the
first two.
### Layer A — MudBlazor palettes (C#)
`DeepDrftShared.Client/Common/DeepDrftPalettes.cs` defines `PaletteLight Light`,
`PaletteDark Dark` (+ `CmsLight`, `EmbedLight`, `EmbedDark`). `MainLayout.razor` mounts
`<MudThemeProvider Theme="DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />`. MudBlazor
injects these as `--mud-palette-*` CSS variables that **flip automatically** when
`IsDarkMode` toggles. This is the part that works: anything reading `--mud-palette-surface`,
`--mud-palette-background`, `--mud-palette-text-primary` inverts correctly for free.
### Layer B — DeepDrft design tokens (CSS)
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two token families:
- **Source tokens** — raw brand colours, *constant across both themes*:
`--deepdrft-navy (#112338)`, `--deepdrft-white (#FAFAF8)`, `--deepdrft-green-accent
(#3D7A68)`, `--deepdrft-soft (#e3e7ec)`, etc. These never change between light and dark.
- **Theme-aware aliases**`--theme-surface`, `--theme-surface-soft`, `--theme-primary…senary`,
`--gradient-base/accent/warm/light`, `--deepdrft-surface`, `--deepdrft-background`. These
**are** redefined inside the `.deepdrft-theme-dark` block (the wrapper class
`MainLayout.ThemeWrapperClass` puts on the root div), so they flip.
The token file's own header comment establishes the intended discipline: source tokens are
"source of truth"; theme-aware aliases are what page CSS is *supposed* to consume so it
"resolve[s] coherently across themes."
### Layer C — component / page CSS
Scoped `*.razor.css` files and the global `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
**This is where the discipline breaks.** Page sections that should track the theme surface
instead reach straight past Layer B and bind to the *constant source tokens* of Layer A
(`--deepdrft-white`, `--deepdrft-navy`, `--deepdrft-soft`). A constant cannot invert — so
these surfaces stay light-on-navy-site no matter the mode.
---
## 2. Root causes (six symptoms → three causes)
The six reported symptoms collapse to **three** root causes. That collapse is the whole
point of doing this as one coherent pass rather than six patches.
### Cause 1 — "neutral surface" sections bind to constant source tokens, so they never invert
*(Symptoms: Home hero-left + footer (#3); About light sections (#4))*
These rules are the smoking gun (all bind a constant, not a theme alias):
- `Home.razor.css``.hero-left`, `.section`, `.section-divider`, `.section-body p`,
`.medium-card`, `.split-right`, `.connect-*``background: var(--deepdrft-white)`,
text `color: var(--deepdrft-navy)`.
- `About.razor.css``.hero-left`, `.hero-image-pane`, `.bio`/process gradients →
`background: var(--deepdrft-white)`, text on `--deepdrft-navy`.
- `DeepDrftFooter.razor.css``.deepdrft-footer``background: var(--deepdrft-white)`,
logo/links text on `--deepdrft-navy` / `--deepdrft-muted`.
`--deepdrft-white` is `#FAFAF8` in **both** `:root` and `.deepdrft-theme-dark` — it is a
brand constant, never re-aliased. So in dark mode these read as bright off-white panels with
dark text floating in a navy site. The fix is **not** to hardcode a dark colour; it is to
**bind these surfaces to a theme-aware alias** that already inverts.
**Critical nuance Daniel flagged:** the fix must be *neutral to the existing navy and green
accent sections.* The page already has sections that are **intentionally** navy/green in
both modes — `.section-dark` (navy), `.split-left` (green), `.cta-banner` (navy), the
`ReleaseHeroOverlay` (dark image). Those are decorative-by-design and must **not** be touched
by the inversion. Only the "default page surface" sections (the ones currently white-because-
light) should flip. This is a *classification* problem first, a recolor second: separate
"neutral surface" from "decorative accent" and only re-token the former.
### Cause 2 — the play-icon chip background binds `--deepdrft-soft` (constant light grey)
*(Symptoms: greyed-out play icon on release heroes / track lists (#5); too-bright player-bar play button (#6))*
`PlayStateIcon.razor.css` `.icon-container` hardcodes `background-color: var(--deepdrft-soft)`
(`#e3e7ec` — a light grey, constant across both themes). `PlayStateIcon` is the **single**
glyph component used by the release heroes, the Cut track rows, *and* the player bar. So one
constant drives all of these:
- Over a **dark hero image / navy track list** → the light-grey chip reads dull and
"greyed-out" (#5). Daniel wants: **moss-green chip background, navy play glyph** in dark mode.
- On the **bright player-surface** → the same light-grey chip reads "very bright" against the
navy dock (#6). Daniel wants: **same green, much less opaque** (a translucent green wash,
not a solid bright fill).
Both are the same `--deepdrft-soft` constant failing to be theme-aware. One component, one
token — fix the token's dark-mode value and both surfaces resolve. Note the two contexts want
*different green treatments* (solid green chip on the hero; translucent green wash in the
player bar), so the chip background should be a **token the player-bar context can override**,
not a single flat value — see §3.
### Cause 3 — popover surface has no theme-aware token; light mode reads "too dark"
*(Symptom: light-theme popover background too dark, wants desaturated navy (#1))*
Two different popover families exist and they are styled inconsistently:
- **Bespoke panels** (visualizer controls, queue, privacy) deliberately use
`--deepdrft-panel-ground` (`#1a1c22`, a dark charcoal) for their dark-glass chrome. These
are *meant* to be dark in both modes — leave them.
- **MudBlazor default popovers** (selects, menus, tooltips, the share popover body) inherit
`--mud-palette-surface`. In light mode `Surface = #FAFAF8`, but elevation-overlay tinting +
the `--deepdrft-panel-ground` charcoal leaking through shared chrome is making them read
darker/muddier than intended. Daniel's ask — "a more desaturated navy" — says the *target*
isn't pure white; it's a **soft desaturated-navy surface**. There is no token for that today,
so each popover improvises.
The fix is a **dedicated theme-aware popover-surface token** (`--deepdrft-popover-surface`)
with a desaturated-navy value in light mode and the existing panel-ground in dark mode, bound
once at the MudPopover surface so every default popover picks it up.
---
## 3. The DRY remediation — token structure
The unifying move: **page/component CSS must bind theme-aware aliases, and any surface that
must invert gets a named alias in `deepdrft-tokens.css` (defined twice — `:root` + `.deepdrft-theme-dark`).**
No surface colour is hardcoded at the component level. This is exactly the Layer-B discipline
the token file's header already declares; the work is making the consumers obey it.
### New / clarified tokens (in `deepdrft-tokens.css`)
| Token | Light (`:root`) | Dark (`.deepdrft-theme-dark`) | Replaces |
|---|---|---|---|
| `--deepdrft-page-surface` | `var(--deepdrft-white)` | `var(--deepdrft-navy)` (ground) or `--deepdrft-navy-mid` (elevated) | the literal `--deepdrft-white` on neutral page sections |
| `--deepdrft-page-text` | `var(--deepdrft-navy)` | `var(--deepdrft-white)` | the literal `--deepdrft-navy` text on neutral sections |
| `--deepdrft-page-text-muted` | `var(--deepdrft-muted)` | `color-mix(... lighter)` | muted body/eyebrow text that must stay legible on dark |
| `--deepdrft-play-chip` | `var(--deepdrft-soft)` | `var(--deepdrft-green-accent)` | `.icon-container` background |
| `--deepdrft-play-glyph` | (current) | `var(--deepdrft-navy)` | play glyph colour in dark |
| `--deepdrft-play-chip-soft` | derived | `color-mix(green-accent ~30%, transparent)` | player-bar translucent variant (#6) |
| `--deepdrft-popover-surface` | desaturated navy (e.g. `color-mix(navy 8%, white)`) | `var(--deepdrft-panel-ground)` | MudPopover default surface (#1) |
Values above are *direction, not final*. Per project memory (decorative-palette contrast
targets the actual WCAG threshold for the element type — large text 3:1, pushing toward
vibrancy), the implementer should tune the exact mixes on screen; the **structure** is the
deliverable here, the hex is theirs to land.
### Why tokens, not per-component fixes
- **One source of truth per concept.** "Neutral page surface," "play chip," "popover surface"
each become *one* token. A future page that needs a neutral surface binds the token and
inverts for free — no new dark-mode rule to remember (the backfill-cliff smell the
*design-for-adaptability* memory warns against).
- **Neutrality to accents is structural, not vigilance-based.** Because only neutral-surface
sections get re-tokened and the decorative navy/green sections keep their explicit brand
colours, the inversion *cannot* accidentally flip a section that's meant to stay navy. The
classification is encoded in *which token a section binds*, not in a reviewer noticing.
- **Player-bar vs. hero divergence is expressible.** Cause 2 needs the same green in two
opacities. A `--deepdrft-play-chip` token + a `--deepdrft-play-chip-soft` override the
player-bar context sets means one green, two contexts, zero duplication.
### What stays untouched (the neutrality guardrail)
`.section-dark`, `.split-left`, `.cta-banner` (Home + About), `ReleaseHeroOverlay` dark-image
chrome, and the bespoke `--deepdrft-panel-ground` panels (visualizer/queue/privacy) keep their
explicit brand colours. They are decorative-by-design and already correct in both modes. The
remediation must **not** route them through the new neutral-surface tokens.
---
## 4. Track / wave breakdown (for clean dispatch)
Sequenced so the token layer lands first and the component re-pointing fans out behind it.
Tracks T2T4 are parallel once T1 is in.
### T1 — Token foundation *(cold-start prerequisite)*
Add the theme-aware tokens from §3 to `deepdrft-tokens.css` — each defined in **both** `:root`
and `.deepdrft-theme-dark`. No component consumes them yet; this is a pure additive token
slice. Tune the dark-mode values on screen. **Load-bearing for everything below.**
- Scope: `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` only.
- Acceptance: tokens resolve to the right value in each mode (verify via devtools); no visual
change yet (nothing binds them).
### T2 — Neutral-surface inversion *(Cause 1 → symptoms #3, #4)*
Re-point the neutral page-surface sections from constant source tokens to `--deepdrft-page-surface`
/ `--deepdrft-page-text` / `--deepdrft-page-text-muted`. **Classify first** — only the neutral
sections; leave `.section-dark` / `.split-left` / `.cta-banner` / hero-overlay alone.
- Scope: `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css`.
- Acceptance: in dark mode the Home hero-left, the medium grid, the footer, and the About
light sections render dark-surface/light-text; the navy and green accent sections are
visually unchanged; light mode is pixel-identical to today.
- Risk: the appbar already has dark-mode handling (`deepdrft-styles.css §5`); confirm the
footer/hero changes don't double-invert anything the appbar rules already cover.
### T3 — Play-chip theming *(Cause 2 → symptoms #5, #6)*
Re-point `.icon-container` background from `--deepdrft-soft` to `--deepdrft-play-chip`; set the
dark play glyph to `--deepdrft-play-glyph` (navy); in the **player-bar context only**, override
the chip to the translucent `--deepdrft-play-chip-soft`.
- Scope: `PlayStateIcon.razor.css` (+ a player-bar-scoped override, likely in
`AudioPlayerBar.razor.css` or a context class on the bar's `.icon-container`).
- Acceptance (dark mode): release-hero + Cut-track-row play chips are **moss-green with a navy
glyph**; the player-bar play button is the **same green but markedly less opaque**; light
mode unchanged. Confirm hover states still read.
- Note: `PlayStateIcon` is shared — verify the chip change is acceptable on **every** mount
(heroes, track rows, player bar) and that the player-bar override is the only context-specific
divergence.
### T4 — Popover surface token *(Cause 3 → symptom #1)*
Introduce `--deepdrft-popover-surface` and bind MudBlazor's default popover surface to it so
light-mode popovers read as soft desaturated-navy rather than the current too-dark muddle.
**Do not** touch the bespoke `--deepdrft-panel-ground` panels.
- Scope: `deepdrft-styles.css` (a `.mud-popover` / popover-surface rule binding the new token);
token already added in T1.
- Acceptance: light-mode default popovers (selects/menus/share body) render desaturated-navy;
dark-mode popovers unchanged; the visualizer/queue/privacy panels are untouched.
- Open question (resolve during T4): confirm whether the "too dark" popover is a MudBlazor
elevation-overlay artifact or panel-ground leakage — the fix differs slightly (override the
overlay tint vs. set the surface). One devtools inspection settles it; flagged so the
implementer checks rather than guesses.
### Dependency shape
`T1 → {T2, T3, T4}`. T1 is the only cold-start item. T2/T3/T4 are independent of each other
and can land in any order or in parallel once T1 is in. None of them touch source code, the
data layer, or the streaming seam — this is a pure CSS-token pass.
---
## 5. Open questions for Daniel
1. **Dark neutral-surface = ground or elevated?** Should the inverted Home/About/footer
surfaces be the navy *ground* (`--deepdrft-navy`, matching the site background — sections
dissolve into one continuous dark field) or *elevated* navy-mid (`--deepdrft-navy-mid`
sections read as distinct raised panels)? Recommend **ground** for the footer/hero (continuous
field, less busy) and let the medium-cards stay as bordered panels on that ground. This is a
taste call; flag for Daniel.
2. **Popover target colour (#1).** "Desaturated navy" — how far from white? Recommend a light
wash (`color-mix(navy ~8%, white)`) so it stays clearly a light-mode surface, not a dark one.
Confirm direction on screen.
3. Everything else (exact green opacity for the player-bar chip, exact muted-text mix) is a
tune-on-screen detail, not a decision gate.
These are the only items that change the shape of the work; the rest is mechanical.