Files
deepdrft/product-notes/cms-public-landing.md
T
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

22 KiB

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.razorAuthorizeRouteView 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.razorMudThemeProvider 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.csMapRazorComponents<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.
  • ThemeDeepDrftPalettes.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:

  • 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 MudThemeProviderIsDarkMode="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.

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.razorCatalogue.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 imageMudImage 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. TitleMudText Typo="Typo.h2" "Deep Drft" (Cormorant via theme), centered (Align="Align.Center").
  3. SubtitleMudText Typo="Typo.subtitle1" "Catalogue Management" (Geist Mono via theme), Class="text-uppercase mud-text-secondary", centered.
  4. SpacerMudSpacer or a Class="my-4" gap.
  5. Login CTAMudButton 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.

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.razorCatalogue.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).