67 Commits

Author SHA1 Message Date
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
41 changed files with 2204 additions and 192 deletions
+6 -3
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). 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. 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.
- **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
+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.
+49
View File
@@ -6,6 +6,55 @@ 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.
+1 -1
View File
@@ -15,7 +15,7 @@
<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>
+3
View File
@@ -107,6 +107,9 @@ builder.Services.AddAuthBlocks(options =>
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
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
{
@@ -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,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>
@@ -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;
}
+18 -1
View File
@@ -3,7 +3,7 @@
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="typeof(Layout.CmsLayout)">
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>
+10
View File
@@ -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
@@ -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,6 +23,8 @@
@* 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. *@
<MudStack Row AlignItems="AlignItems.Center">
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
@@ -35,5 +37,5 @@
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
</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,6 +29,7 @@
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
host only toggles open/closed and centers the panel — it stays purely presentational. *@
<div class="dd-accent-icon">
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
@@ -38,6 +39,7 @@
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
@@ -25,7 +25,7 @@
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. Clear your browser data and the tag&#8217;s gone.</p>
<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>
@@ -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 {
@@ -44,19 +44,19 @@
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,
.deepdrft-footer-links button:hover { color: var(--deepdrft-navy); }
.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);
}
/* PRIVACY trigger reset button chrome so it reads as a link, not a button element.
@@ -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="/">
@@ -48,7 +48,7 @@
</div>
@* Mobile Menu *@
<div class="d-flex d-sm-none">
<div class="d-flex d-md-none">
<nav class="@NavClass">
<MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
@@ -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;
@@ -226,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
+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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 525 KiB

+140 -28
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;
@@ -950,8 +1061,8 @@ body:has(.deepdrft-privacy-overlay) {
display: flex;
flex-direction: column;
width: min(90vw, 480px);
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;
@@ -962,7 +1073,7 @@ body:has(.deepdrft-privacy-overlay) {
align-items: center;
justify-content: space-between;
padding: 0.85rem 0.85rem 0.85rem 1rem;
border-bottom: 1px solid var(--deepdrft-border-light);
border-bottom: 1px solid var(--deepdrft-panel-border);
}
/* Mono uppercase eyebrow — matches queue modal title. */
@@ -971,27 +1082,28 @@ body:has(.deepdrft-privacy-overlay) {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
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-white) !important;
color: var(--deepdrft-panel-text) !important;
}
.deepdrft-privacy-modal-close:hover {
opacity: 1;
}
/* Privacy copy: same mono treatment as the former inline paragraph, but readable on dark ground. */
/* 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-white);
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);
}
@@ -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);
}
+94 -31
View File
@@ -342,41 +342,104 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
---
## Phase 18Theme / Dark-Mode Remediation (DRY token pass)
## Phase 19AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)
A punch-list of six theming symptoms Daniel reported — five in dark mode, one in light —
that all trace to **three** root causes in how component/page CSS bypasses the theme-aware
token layer and binds *constant* source tokens instead. Resolved as one coherent token pass,
not six per-component patches. Full design, architecture map, root-cause analysis, token
table, and track breakdown: `product-notes/theme-dark-mode-remediation.md`.
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`.
**Root-cause collapse (six symptoms → three causes):**
- **Cause 1 — neutral surfaces don't invert.** Home hero-left + footer (#3) and About light
sections (#4) hardcode `background: var(--deepdrft-white)` / text on `--deepdrft-navy`
brand *constants* that are identical in `:root` and `.deepdrft-theme-dark`, so they cannot
flip. Fix: bind a theme-aware `--deepdrft-page-surface` / `--deepdrft-page-text` alias. The
inversion must stay **neutral to the intentionally navy/green decorative sections**
(`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) — a classify-then-recolor job.
- **Cause 2 — play chip binds a constant grey.** `PlayStateIcon.razor.css` `.icon-container`
hardcodes `--deepdrft-soft` (#e3e7ec). One shared component drives the release-hero chip, the
Cut track rows, *and* the player bar — so it reads "greyed-out" over dark heroes (#5) and "too
bright" on the navy player surface (#6). Fix: theme-aware `--deepdrft-play-chip` (moss-green +
navy glyph in dark) with a translucent `--deepdrft-play-chip-soft` override for the player bar.
- **Cause 3 — no theme-aware popover surface.** Light-mode default MudPopovers read "too dark"
(#1); there's no token for the wanted "desaturated navy." Fix: a `--deepdrft-popover-surface`
token; leave the bespoke `--deepdrft-panel-ground` panels alone.
**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.**
**Sequenced as four tracks, `T1 → {T2, T3, T4}`.** T1 (additive token foundation in
`deepdrft-tokens.css`) is the cold-start prerequisite; T2 (neutral-surface inversion), T3
(play-chip theming), T4 (popover token) fan out behind it and are mutually independent. Pure
CSS-token pass — no source code, data layer, or streaming-seam changes. Prior art:
`product-notes/track-card-theming.md` solved this exact class of theme-aware recolor once
already; this generalizes the fix from one component to the pattern.
**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`).
**Open questions for Daniel (spec §5):** (1) dark neutral surface = navy *ground* (continuous
field — recommended for footer/hero) vs. *elevated* navy-mid (distinct panels); (2) popover
target distance from white (recommend a light `color-mix(navy ~8%, white)` wash). Exact green
opacity + muted-text mixes are tune-on-screen details, not decision gates.
**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.
---
@@ -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.