Files
deepdrft/product-notes/cms-public-landing.md
daniel-c-harvey 5fb46bf5eb docs(product): spec CMS public landing page (Phase 13)
Splash owns /, catalogue moves to /catalogue, authed users redirected
via HierarchicalRoleAuthorizeView. Skipper's public-layout pattern,
branded to DeepDrft. Adds Phase 13 to PLAN.md.
2026-06-17 11:44:33 -04:00

344 lines
22 KiB
Markdown

# CMS Public Landing — a true public face for DeepDrftManager — Design Spec
Status: **proposed** (2026-06-17). Author: product-designer. Date: 2026-06-17.
Implementer: staff-engineer. Reference pattern: Skipper `Home.razor` / `MainHomeLayout.razor`.
## 0. Goal
Give `DeepDrftManager` (the CMS) a public face: an unauthenticated splash at `/` with DeepDrft
branding and a single clear **Login** call-to-action. Authentication happens beyond it. Today an
anonymous hit to `/` falls straight through to the login form (the catalogue page at `/` carries
`[Authorize]`, so `AuthorizeRouteView` redirects). After this change, an anonymous visitor sees a
composed, on-brand splash; an authenticated admin is sent onward to the catalogue without ever seeing
the splash.
This is **additive**. The authenticated admin experience (catalogue dashboard, `CmsLayout`, all CMS
pages) is preserved unchanged except for the catalogue's route moving off `/`.
---
## 1. What exists today (confirmed from live source 2026-06-17)
- **`Components/Pages/Index.razor`** — `@page "/"`, `@attribute [Authorize]`, `@layout
Layout.CmsLayout`. This **is** the authenticated catalogue dashboard (Tracks / Releases / Genres
summary cards, each loading a count concurrently). No public face exists.
- **`Components/Routes.razor`** — `AuthorizeRouteView` with `DefaultLayout = Layout.CmsLayout`.
`NotAuthorized`: authenticated-but-unauthorized → `RedirectToAccessDenied`; unauthenticated →
`RedirectToLogin` (the AuthBlocksWeb component, not a Manager-local one). `AdditionalAssemblies`
already includes `typeof(AuthBlocksWeb._Imports).Assembly`.
- **`Components/Layout/CmsLayout.razor`** — `MudThemeProvider IsDarkMode="false" Theme="DeepDrftPalettes.Cms"`
(light-only, solid navy AppBar). Dense AppBar "Deep Drft — Admin" + a "Back to site" home button
(`Href="/"`). Providers (Popover/Dialog/Snackbar) live here.
- **`Program.cs`** — `MapRazorComponents<App>().AddInteractiveServerRenderMode()...AllowAnonymous()`.
Endpoint auth is anonymous by design (JWT lives in browser localStorage, never reaches the server on
a nav); **page authorization is owned entirely by `AuthorizeRouteView`**. Consequence: a routable
page with **no `[Authorize]` attribute renders for everyone**, no server 401, no redirect. This is
the seam the splash uses.
- **Auth components available** (from `Cerebellum.AuthBlocks.Web`, assembly already wired into the
router): `[HierarchicalRoleAuthorize]` attribute and `<HierarchicalRoleAuthorizeView>` view
component. Bare = "requires authenticated"; with roles = walks the role hierarchy.
- **No `wwwroot/img/` directory exists** in `DeepDrftManager` today. The host's only wwwroot asset is
`app.css`. The hero asset and its folder are net-new.
- **Theme** — `DeepDrftPalettes.Cms` (in `DeepDrftShared.Client.Common`): navy `#0D1B2A` ground /
green-accent `#3D7A68` / warm off-white `#FAFAF8`; typography Cormorant Garamond (display) / Geist
Mono (`Subtitle1`/`Button`/`Caption`) / DM Sans (body). This is the DeepDrft identity — **do not
import Skipper's nautical styling.** Borrow Skipper's *structure*, not its look.
### What we borrow from Skipper (pattern, not pixels)
Skipper splits a public **`MainHomeLayout`** (app bar with logo + dark toggle, centered
`MudContainer`) from the authenticated app layout, routes both at `/`, and inside `Home.razor` wraps
the splash body in `<HierarchicalRoleAuthorizeView>`: `Authorized` → `<RedirectToDashboard/>`,
`NotAuthorized` → hero image + title + subtitle + Login/Register buttons in a centered `MudStack`.
We adopt: (a) a **dedicated public layout** distinct from `CmsLayout`, (b) the
**`HierarchicalRoleAuthorizeView` redirect-the-authed-user** idiom at `/`, (c) the **centered hero +
single CTA** composition. We diverge on branding and drop Register (CMS access is invite/seed-only —
there is no public registration path; see §4).
---
## 2. Routing reshape — the load-bearing decision
**Recommendation: Option A — splash owns `/`, catalogue moves to `/catalogue`, authed users are
redirected onward from the splash via `HierarchicalRoleAuthorizeView`.**
This is the Skipper pattern transplanted directly, and it is the right one here. Three options were
weighed:
### Option A (recommended) — splash at `/`, catalogue at `/catalogue`, redirect authed from `/`
- New `Home.razor` takes `@page "/"`, **no `[Authorize]`**, public layout. Body wrapped in
`<HierarchicalRoleAuthorizeView>`: `Authorized` → redirect to `/catalogue`; `NotAuthorized` → the
hero + Login CTA.
- `Index.razor` (the catalogue) changes its route from `@page "/"` to `@page "/catalogue"`,
everything else unchanged (keeps `[Authorize]` + `CmsLayout`).
- **Pros:** Mirrors Skipper exactly (proven). `/` is a clean public URL. Anonymous and authed users
share one entry point; the redirect is declarative and lives next to the splash it guards. No
`Routes.razor` change. The catalogue page is otherwise untouched.
- **Cons:** The catalogue's URL changes — internal links to `/` that *meant* "the dashboard" must
repoint to `/catalogue` (see §6 migration checklist; the live count is small — `CmsLayout`'s
"Back to site" button and any nav). One new redirect component (`RedirectToCatalogue`) or an inline
`NavigationManager` redirect.
### Option B — splash at `/`, catalogue stays at `/`, layout chosen by auth state in `Routes.razor`
Skipper's *other* mechanism: `Routes.razor` computes `MainHomeLayout` vs app layout from auth state in
`OnParametersSetAsync`, and both the splash and catalogue claim `@page "/"` is **not** possible (route
collision). To keep the catalogue at `/`, the splash would have to be conditionally rendered *inside*
the catalogue page or the layout — collapsing two concerns into one component.
- **Pros:** Catalogue URL never changes; zero link migration.
- **Cons:** Forces splash + dashboard logic into one routable component or bleaks auth-branching into
the layout. Violates the project's preference for one-source/clean seams. The splash is structurally
a *different page* with a *different layout*; conflating them is the wrong abstraction. Rejected.
### Option C — splash at a distinct route (`/welcome`), `/` unchanged
Splash lives at `/welcome`; `/` keeps the catalogue with `[Authorize]`, so anonymous `/` still
redirects to login (today's behavior), and nothing lands on the splash unless explicitly routed there.
- **Pros:** Smallest diff; no link migration.
- **Cons:** Defeats the goal. The "public face" is the thing an anonymous visitor sees when they
arrive at the app root. A splash nobody routes to is dead. Rejected.
**Why A over the rest:** the goal is *"`/` is the public face."* That demands the splash own `/`,
which demands the catalogue move. The redirect-authed-user idiom keeps the admin's experience
seamless (they never see the splash). Option A is the only one that satisfies the goal without
conflating page concerns. The link-migration cost is real but small and mechanical.
### Where AuthBlocks fits
- The splash body is wrapped in **`<HierarchicalRoleAuthorizeView>`** (bare, no roles — "is this
visitor authenticated at all?").
- `Authorized` slot → redirect to `/catalogue` (mirrors Skipper's `RedirectToDashboard`). The CMS
further gates the catalogue on the `Admin` role via the existing `[Authorize]` /
`AuthorizeRouteView` path — so a logged-in non-admin still hits `RedirectToAccessDenied` at
`/catalogue`, which is correct and unchanged.
- `NotAuthorized` slot → the hero + Login CTA.
- The Login button links to **`/account/login?returnUrl={Uri.EscapeDataString("catalogue")}`** so a
successful login lands the admin on the catalogue, not back on the splash. (Skipper uses
`returnUrl=dashboard`; same idiom, our route.)
- `Routes.razor` needs **no change** — its `AuthorizeRouteView` already renders no-`[Authorize]` pages
for anonymous users, and the host is already `AllowAnonymous` at the endpoint.
---
## 3. New + changed files and their responsibilities
### 3.1 New: `Components/Layout/CmsHomeLayout.razor` (public splash layout)
A public counterpart to `CmsLayout`, deliberately lighter. Responsibilities:
- Own its **own `MudThemeProvider`** — `IsDarkMode="false" Theme="DeepDrftPalettes.Cms"` (same theme
as the admin surface, so the splash is of-a-piece with the CMS, navy AppBar and all). Splash is
light-only like the rest of the CMS; no dark toggle (the CMS has none today — do not introduce one
here).
- Own a **minimal provider set**: include `MudPopoverProvider` (tooltips on the splash, if any need
it) and skip `MudDialogProvider` / `MudSnackbarProvider` unless a splash interaction needs them
(it does not in this spec — omit them to keep the layout lean).
- A **slim AppBar** consistent with `CmsLayout`'s navy bar but read as a *front door*, not an admin
console: brand text "Deep Drft" (or "Deep Drft — Admin" to set expectation; see §4 open question),
`Typo.h6`, DM-Sans, letter-spacing as in `CmsLayout`. No "Back to site" home button (the splash
*is* the front door). No nav drawer.
- `MudMainContent` wrapping a centered `MudContainer MaxWidth="Small"` (the hero is a focused column,
not full-bleed). Vertical centering via MudBlazor utility classes (`d-flex`, `flex-column`,
`justify-center`, `align-center`) and a min-height tied to the viewport (`Style="min-height:
calc(100vh - <appbar>);"` or the `mud-height-full` utility on the content) so the hero sits centered
in the page, not jammed at the top. Prefer MudBlazor utilities; the single inline `min-height` is the
one justified bespoke style (no utility expresses "viewport minus app bar" cleanly).
- Include the `<div id="blazor-error-ui">` block (copy from `CmsLayout`) so the InteractiveServer
error UI is present on the public surface too.
**Why a separate layout rather than reusing `CmsLayout`:** `CmsLayout` is built for the admin console
(full-width container `MaxWidth.False`, "Back to site" affordance, dialog/snackbar providers for CRUD
flows). The splash wants a centered narrow column and a front-door AppBar. Forcing one layout to do
both would mean auth-branching inside the layout — the same conflation Option B was rejected for.
This mirrors Skipper's `MainHomeLayout` vs app-layout split exactly.
### 3.2 New: `Components/Pages/Home.razor` (the splash page)
- `@page "/"`, `@layout Layout.CmsHomeLayout`, **no `[Authorize]`**.
- `<PageTitle>Deep Drft — Admin</PageTitle>` (or "Deep Drft" — see §4).
- Body = `<HierarchicalRoleAuthorizeView>` with the two slots from §2 (`Authorized` → redirect to
`/catalogue`; `NotAuthorized` → hero + CTA per §5).
- No data fetch, no injected services beyond `NavigationManager` (only if doing an inline redirect
rather than a `RedirectToCatalogue` component). Keep it presentational.
### 3.3 New: `Components/RedirectToCatalogue.razor` (optional, recommended)
Mirrors the existing `RedirectToAccessDenied.razor` exactly (inject `NavigationManager`, redirect in
`OnInitialized`). Target `/catalogue`. Use `NavigateTo("/catalogue", forceLoad: false)` —
client-side nav is fine here (unlike Skipper's `RedirectToDashboard` which uses `forceLoad: true`;
that was a Skipper-specific concern. Default to no force-load and let staff-engineer confirm against
the JWT-in-localStorage auth-state timing — if the authed redirect mis-fires on first paint, escalate
to `forceLoad: true` as Skipper does). Keeping this as a named component matches the existing
`RedirectToAccessDenied` convention; an inline redirect in `Home.razor` is acceptable if staff-engineer
prefers fewer files.
### 3.4 Changed: `Components/Pages/Index.razor` (the catalogue)
- **One-line change:** `@page "/"` → `@page "/catalogue"`. Everything else (the `[Authorize]`,
`CmsLayout`, the three summary cards, the concurrent loaders) is unchanged.
- Optional rename for clarity (`Index.razor` → `Catalogue.razor`): **defer.** Not required; a rename
ripples through `ILogger<Index>` and any references. Out of scope for this spec — flag only if
staff-engineer is already touching those lines.
### 3.5 Changed: `Components/Layout/CmsLayout.razor` (the "Back to site" button)
- The "Back to site" home button currently has `Href="/"`. Post-reshape, `/` is the **public splash**,
not the catalogue. Two readings:
- If "Back to site" means *the admin home / catalogue*, repoint to `Href="/catalogue"`.
- If "Back to site" means *the public DeepDrft site* (i.e., `DeepDrftPublic`), it should point at the
public site's URL, not anything in Manager — but that cross-host URL isn't configured in the CMS
today, so this is likely **not** the intent.
- **Recommendation:** repoint to `/catalogue` (the in-CMS home). The tooltip text "Back to site" is
then slightly off — consider "Catalogue" or "Home". Staff-engineer's call on wording; the *Href*
must change to `/catalogue` regardless, or the admin's home button lands them on the splash, which
then bounces them back to `/catalogue` via the authed-redirect — functional but an ugly double-hop.
---
## 4. Branding, copy, and the no-Register decision
- **Identity:** DeepDrft navy/green/off-white via `DeepDrftPalettes.Cms`. Display type Cormorant
Garamond (it comes free from the theme's `Typo.h2`/`h3` etc. — do **not** hand-set font-family in
markup; use `Typo` and let the theme resolve it). Body DM Sans, button/caption Geist Mono — again,
theme-resolved.
- **No Register CTA.** Skipper offers Register (marina staff self-onboard with a code). The DeepDrft
CMS is gated by AuthBlocks login + hierarchical `Admin` role, seeded/invited — there is no public
registration page in `DeepDrftManager`, and AuthBlocksWeb's registration (if present) is not a path
we want to advertise on the front door of an admin tool. **Single Login CTA only.** If Daniel later
wants an invite-code flow, that is a separate feature, not part of this splash.
- **Copy (proposed, Daniel may revise):**
- Title: **"Deep Drft"** (Cormorant, `Typo.h2`, the brand) — or stylized **"DEEP DRFT"** uppercase
to echo Skipper's `SKIPPER` treatment. Recommend brand-cased "Deep Drft" to match the public
site's wordmark rather than Skipper's all-caps.
- Subtitle: **"Catalogue Management"** or **"Collective Content Management"** (Geist Mono,
`Typo.subtitle1`, uppercase via `text-uppercase`, muted/secondary) — names what's behind the door
without overselling. Recommend **"Catalogue Management"**.
- CTA button label: **"Login"**.
- **Open question for Daniel (one):** should the AppBar + title say **"Deep Drft"** (front-door, clean)
or **"Deep Drft — Admin"** (sets the expectation that this is the admin tool, consistent with
`CmsLayout`'s AppBar)? Recommend **"Deep Drft — Admin"** in the AppBar for continuity with the
authenticated surface, and **"Deep Drft"** as the large hero title. This is a copy call, not a
structural one — staff-engineer can implement either without rework.
---
## 5. Hero + CTA composition
A centered, single-column hero in `MudContainer MaxWidth="Small"`, vertically centered in the
viewport. Composition top-to-bottom inside a `MudStack AlignItems="AlignItems.Center" Spacing="4"`:
1. **Hero image** — `MudImage Fluid="true" Src="img/cms-hero.png"` (asset path §5.1). Constrain its
width with the container (`MaxWidth="Small"`) so it reads as a focused emblem, not a full-bleed
banner. If Daniel's asset is wide/cinematic rather than emblem-shaped, wrap it in a
`MudPaper`-backed rounded region with `Style="border-radius: 8px; overflow: hidden;"` — but default
to the plain `MudImage` and let the asset dictate (see §5.1 open note).
2. **Title** — `MudText Typo="Typo.h2"` "Deep Drft" (Cormorant via theme), centered
(`Align="Align.Center"`).
3. **Subtitle** — `MudText Typo="Typo.subtitle1"` "Catalogue Management" (Geist Mono via theme),
`Class="text-uppercase mud-text-secondary"`, centered.
4. **Spacer** — `MudSpacer` or a `Class="my-4"` gap.
5. **Login CTA** — `MudButton Variant="Variant.Filled" Color="Color.Primary"
Href="/account/login?returnUrl=..."` (see §2), full-width within a `MaxWidth.Small` inner region or
a fixed comfortable width (`Style="min-width: 200px;"`). Label "Login". Single button, centered.
This is the Skipper hero stack (image / title / subtitle / spacer / CTA) with the Register row and the
two-column button grid removed (one CTA needs no grid). No bespoke CSS beyond the optional rounded-image
wrapper and the layout's one `min-height` — everything else is MudBlazor `Typo`, `Color`, and spacing
utilities.
### 5.1 Hero asset path and reference
- **Asset location:** `DeepDrftManager/wwwroot/img/cms-hero.png` (Daniel supplies the file). This
creates the `wwwroot/img/` directory, which does not exist today — staff-engineer creates the folder
when the asset is dropped in.
- **Reference in markup:** `Src="img/cms-hero.png"` (relative; `MapStaticAssets()` in `Program.cs`
serves `wwwroot/`). Matches Skipper's `Src="img/skipper-hero.png"` idiom exactly. Do **not** wrap in
`@Assets[...]` / fingerprinting unless the project's static-asset fingerprinting convention requires
it for cache-busting — for a single rarely-changing hero, the plain relative path is fine and matches
the public site's `img/...` references.
- **Filename:** `cms-hero.png` recommended. If Daniel's asset is a JPEG, name it `cms-hero.jpg` and
update the `Src`. One asset, one reference — keep it boring.
- **Open note (non-blocking):** the spec assumes an emblem/portrait-ish hero suited to a centered
narrow column (Skipper uses a boat illustration). If Daniel's asset is a wide cinematic photo, the
composition still works but consider `MaxWidth="Medium"` on the container and the rounded-paper wrap
from §5.1. Staff-engineer adjusts container width to the asset; no design rework needed.
---
## 6. Migration checklist (link repointing)
Every internal reference that meant "the catalogue lived at `/`" must move to `/catalogue`:
1. **`CmsLayout.razor`** "Back to site" button `Href="/"` → `Href="/catalogue"` (§3.5).
2. **`Index.razor`** summary-card "View" buttons → already navigate to `/tracks`, unaffected.
3. **Any nav menu / breadcrumb / link in CMS components pointing at `/`** as "home/dashboard" →
`/catalogue`. staff-engineer: grep `DeepDrftManager` for `Href="/"` and `NavigateTo("/")` /
`NavigateTo("/", ` and repoint the ones that mean *catalogue* (leave any that genuinely mean *the
public front door* pointing at `/`).
4. **Login `returnUrl`** anywhere it defaults to the old root → `catalogue`.
This grep-and-repoint is the entire cost of Option A. It is mechanical and small (the CMS is a compact
host), but it must be exhaustive or an admin lands on the splash and double-hops.
---
## 7. Constraints honored
- **DRY / MudBlazor-first:** reuses `DeepDrftPalettes.Cms` (no new palette), reuses the
`RedirectToAccessDenied` redirect idiom for `RedirectToCatalogue`, reuses `HierarchicalRoleAuthorizeView`
(no new auth machinery), composes from `MudAppBar`/`MudContainer`/`MudStack`/`MudText`/`MudImage`/
`MudButton`. Bespoke CSS limited to one layout `min-height` and an optional rounded-image wrapper.
- **No new shared component worth extracting.** The splash is host-specific (CMS front door); it does
not belong in `DeepDrftShared.Client`. The public site's hero idiom (`ReleaseHeroOverlay`,
full-bleed background + overlaid metadata) is a *different* composition (content hero, not a login
gate) — do not try to reuse it here; the shapes don't match and forcing it would be the wrong borrow.
If a *second* gated host ever needs a login splash, revisit extracting a `LoginSplash` shared
component then — not now (YAGNI; one consumer).
- **Render mode:** no explicit `@rendermode` override anywhere — the host's global
`AddInteractiveServerRenderMode()` governs, per DeepDrftManager convention. The splash is static
presentational content + one auth-view + links; it needs no interactivity of its own, but inherits
the host render mode without an override (correct).
- **Admin experience intact:** only the catalogue's *route* changes; its page, layout, and behavior are
untouched. Authed admins are redirected past the splash and never see it.
---
## 8. Acceptance criteria
1. An **anonymous** visitor navigating to `/` sees the splash: navy CMS-themed front-door AppBar,
centered hero image (from `wwwroot/img/cms-hero.png`), "Deep Drft" title (Cormorant), a
"Catalogue Management" subtitle (Geist Mono, uppercase, muted), and a single filled "Login" button.
No redirect to the login form occurs; the splash renders.
2. Clicking **Login** navigates to `/account/login` with `returnUrl` resolving to `/catalogue`; a
successful login lands the admin on the catalogue.
3. An **authenticated admin** navigating to `/` is redirected to `/catalogue` (the catalogue
dashboard) without the splash flashing/persisting.
4. An **authenticated non-admin** (logged in, lacking `Admin` role) hitting `/` is redirected toward
`/catalogue` and then to access-denied via the existing `AuthorizeRouteView` path — behavior
unchanged from today's catalogue gating.
5. The **catalogue dashboard** is reachable at `/catalogue`, renders under `CmsLayout` with
`[Authorize]`, and its three summary cards load as before. No regression.
6. `CmsLayout`'s home/"Back to site" button lands an admin on `/catalogue`, not on the splash.
7. The splash uses the **shared `DeepDrftPalettes.Cms` theme** (navy AppBar, off-white ground) — it
reads as the same product as the admin console, not as Skipper and not as a generic MudBlazor page.
8. No `@rendermode` override is introduced; no new palette is introduced; no new entry appears in
`DeepDrftShared.Client`.
9. (If `cms-hero.png` is absent at build time) the page still compiles and renders — only the image is
broken. The splash must not hard-depend on the asset to function (it is an `<img>` `src`, so this
holds by construction).
---
## 9. Out of scope / deferred
- **Register / invite-code flow** — explicitly omitted (§4). Separate feature if ever wanted.
- **Dark-mode toggle on the splash** — the CMS is light-only today; do not introduce a toggle here.
- **Renaming `Index.razor` → `Catalogue.razor`** — optional cleanup, deferred (§3.4).
- **Cross-host "back to the public site" link** — if "Back to site" should point at `DeepDrftPublic`
rather than `/catalogue`, that needs the public site URL configured in the CMS (it isn't today).
Deferred; default to `/catalogue` (§3.5).
- **Extracting a shared `LoginSplash` component** — YAGNI with one consumer (§7).