Compare commits
67 Commits
5298cab9b1
...
64e1f71e18
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e1f71e18 | |||
| 7807d4ebe1 | |||
| 4410132409 | |||
| 00ff9e2702 | |||
| bb086e5869 | |||
| 674d772986 | |||
| ee296db7f6 | |||
| 8a4da2f0b9 | |||
| c28a2b1cf5 | |||
| 1427c92092 | |||
| 2fbb1c9b95 | |||
| 4c56eededc | |||
| f9d99b2c98 | |||
| 59608a23c5 | |||
| 2ddc57edb1 | |||
| 0bb656a512 | |||
| bb5a1fcad4 | |||
| 896b37792e | |||
| 2619fc67c8 | |||
| 4c14c67c33 | |||
| 494668bf24 | |||
| c4e22c706c | |||
| c747f3200f | |||
| 1dd1646cce | |||
| 6bbec2fc8e | |||
| 0c22ce8f09 | |||
| 67645cfd05 | |||
| 2591710f09 | |||
| 30999b038c | |||
| b5106d090f | |||
| a2ed334d0d | |||
| 9300c794b4 | |||
| 95dd48018a | |||
| c21b85afdf | |||
| 234a57d6b7 | |||
| 4bec507aab | |||
| a30d15f79d | |||
| b90604d311 | |||
| 77d0562b08 | |||
| aeda7e67a8 | |||
| bd9c67fc65 | |||
| 62fe27224c | |||
| 0708bb7352 | |||
| e6d5b9b77a | |||
| 04847391ad | |||
| 3d71b6836e | |||
| 833b5a921e | |||
| 3bf95538bd | |||
| eb7e977f3c | |||
| 0b8593950b | |||
| 51ac1a76de | |||
| 949bccfb8e | |||
| cfaf63468d | |||
| d6dcd82a53 | |||
| 3485acf3a8 | |||
| c04c2a9e98 | |||
| f1276faabc | |||
| 6029e226d5 | |||
| 135cc48301 | |||
| 54766fd5fc | |||
| fcc95b9195 | |||
| 042641d841 | |||
| 0358df82ac | |||
| 0f7088fe86 | |||
| 5408d0779c | |||
| abe94953b9 | |||
| 03fdcda054 |
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 T1–T4):** 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 1–3 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 1–3. 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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’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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -342,41 +342,104 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 18 — Theme / Dark-Mode Remediation (DRY token pass)
|
||||
## Phase 19 — AuthBlocks 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 1–2 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 1–2 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 1–2 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
|
||||
(OQ6–OQ9) 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:106–109`; `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 137–172).
|
||||
- `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` (137–172) 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.
|
||||
Reference in New Issue
Block a user