From 5f2212a13c54ea6517924006722890523f52ac12 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 13:23:36 -0400 Subject: [PATCH 1/2] Add the theme toggle, reconnect display and /ui showcase The root carries the stored theme (light, dark) or none to follow the system; the header toggle keeps the choice in a cookie the host reads. Blazor's reconnect state is shown by the shell's own markup and the stylesheet, so the strict CSP holds. /ui replaces /ui-spike for any signed-in user and renders every component with its keyboard hints. --- CONTRIBUTING.md | 2 + src/FluentCMS.Admin/AdminOptions.cs | 7 - .../AdminServiceCollectionExtensions.cs | 7 +- src/FluentCMS.Admin/Components/App.razor | 10 +- .../Components/Layout/MainLayout.razor | 1 + .../Components/Layout/ReconnectModal.razor | 33 +++ .../Components/Layout/ThemeToggle.razor | 20 ++ .../Components/Pages/Showcase.razor | 201 ++++++++++++++++++ .../Components/Pages/UiSpike.razor | 122 ----------- src/FluentCMS.Admin/Styles/app.css | 99 ++++++--- src/FluentCMS.Admin/ThemePreference.cs | 16 ++ src/FluentCMS.Admin/wwwroot/js/admin.js | 90 +++++++- src/FluentCMS.Host/CookieThemePreference.cs | 8 + src/FluentCMS.Host/FluentCmsHost.cs | 3 +- .../Security/SecurityHeaders.cs | 2 +- tests/FluentCMS.IntegrationTests/HostTests.cs | 58 +++-- .../Identity/ExternalSignInTests.cs | 4 +- .../Identity/SignInTests.cs | 14 +- .../WebHardeningTests.cs | 32 +++ .../FluentCMS.Tests/Admin/AdminRoutesTests.cs | 2 +- .../Admin/ReconnectModalTests.cs | 63 ++++++ tests/FluentCMS.Tests/Admin/ShowcaseTests.cs | 74 +++++++ .../FluentCMS.Tests/Admin/StylesheetTests.cs | 30 +++ .../FluentCMS.Tests/Admin/ThemeToggleTests.cs | 50 +++++ .../ProjectReferenceRulesTests.cs | 2 +- 25 files changed, 748 insertions(+), 202 deletions(-) delete mode 100644 src/FluentCMS.Admin/AdminOptions.cs create mode 100644 src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor create mode 100644 src/FluentCMS.Admin/Components/Layout/ThemeToggle.razor create mode 100644 src/FluentCMS.Admin/Components/Pages/Showcase.razor delete mode 100644 src/FluentCMS.Admin/Components/Pages/UiSpike.razor create mode 100644 src/FluentCMS.Admin/ThemePreference.cs create mode 100644 src/FluentCMS.Host/CookieThemePreference.cs create mode 100644 tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/ShowcaseTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/StylesheetTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/ThemeToggleTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f80bf6be..2c42914f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,8 @@ To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host o On a fresh database the admin asks for the first administrator at `/setup`; every other admin page needs a signed-in user. Data Protection keeps the keys that protect sign-in cookies, anti-forgery tokens and stored secrets in `DataProtection__KeysDirectory` (default `data-protection-keys` under the content root). Put it on storage that survives restarts and upgrades and that only the host's account can read; losing it signs everyone out. Sign-in and setup posts are limited per client address by `SignInRateLimit__PermitLimit` and `SignInRateLimit__Window`, ten a minute by default. +Signed in, `/ui` shows every component in `src/FluentCMS.Admin/Components/Ui` with its keyboard hints. Switch the theme from the header and the direction with the link on the page. A new component goes on that page in the same pull request; a test fails when one is missing. + People can also sign in through one OpenID Connect provider (Keycloak, Authentik, Microsoft Entra, Google and similar). Set `Authentication__OpenIdConnect__Authority`, `Authentication__OpenIdConnect__ClientId` and `Authentication__OpenIdConnect__ClientSecret`, optionally `Authentication__OpenIdConnect__DisplayName` for the button, and register `https:///signin-oidc` as the redirect URI at the provider. Without an authority and client id the option does not appear. Users are not created from the provider: the first sign-in links the provider's account to the user with the same email, and only if the provider marks the email as verified. Later sign-ins match on the provider's issuer and subject. A provider that never sends `email_verified`, such as Microsoft Entra, needs `Authentication__OpenIdConnect__AssumeVerifiedEmail=true`; set it only if that provider issues verified addresses alone. ## Database migrations diff --git a/src/FluentCMS.Admin/AdminOptions.cs b/src/FluentCMS.Admin/AdminOptions.cs deleted file mode 100644 index 1998e11f8..000000000 --- a/src/FluentCMS.Admin/AdminOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FluentCMS.Admin; - -public sealed class AdminOptions -{ - // The /ui-spike showcase is reachable only where the host enables it (Development), and like every admin page only when signed in. - public bool ShowcaseEnabled { get; set; } -} diff --git a/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs b/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs index 0e4852e72..ad236b406 100644 --- a/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs +++ b/src/FluentCMS.Admin/AdminServiceCollectionExtensions.cs @@ -4,12 +4,9 @@ namespace FluentCMS.Admin; public static class AdminServiceCollectionExtensions { - public static IServiceCollection AddFluentCmsAdmin(this IServiceCollection services, Action? configure = null) + // The pages use Core's services and what the host reads from the request (the initiator, the theme); nothing of the admin's own yet. + public static IServiceCollection AddFluentCmsAdmin(this IServiceCollection services) { - var options = new AdminOptions(); - configure?.Invoke(options); - services.AddSingleton(options); - return services; } } diff --git a/src/FluentCMS.Admin/Components/App.razor b/src/FluentCMS.Admin/Components/App.razor index 5e42cde71..8c4a37c80 100644 --- a/src/FluentCMS.Admin/Components/App.razor +++ b/src/FluentCMS.Admin/Components/App.razor @@ -1,7 +1,8 @@ @inject NavigationManager Navigation +@inject IThemePreference Theme - + @@ -13,6 +14,7 @@ + @@ -20,12 +22,10 @@ @code { - // The root element carries the direction and the theme. Until the shell stores a preference, - // both come from the query string so the spike and the showcase can switch them: ?dir=rtl&theme=dark. + // The root element carries the direction and the theme. Without a stored theme there is no class and the stylesheet + // follows the system preference. The direction comes from the query string until the UI culture sets it: ?dir=rtl. private string Direction => Query("dir") == "rtl" ? "rtl" : "ltr"; - private string ThemeClass => Query("theme") == "dark" ? "dark" : ""; - private string? Query(string name) { var query = System.Web.HttpUtility.ParseQueryString(Navigation.ToAbsoluteUri(Navigation.Uri).Query); diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index 284087184..827d699a9 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -17,6 +17,7 @@ } +
diff --git a/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor b/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor new file mode 100644 index 000000000..2d1dc37e7 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor @@ -0,0 +1,33 @@ +@inject IStringLocalizer L + +@* Blazor uses the element with this id instead of its default reconnect display, whose injected style element the CSP blocks. + It reports the state as a class on the element: app.css shows the matching part and admin.js runs the buttons. + Not modal, so the page stays readable and the client-side overlays keep working until the circuit is back. *@ +
+

@L["Connection lost. Rejoining the server…"]

+

@_retrying[0]@_retrying.ElementAtOrDefault(1)

+
+

@L["Could not rejoin the server."]

+ +
+
+

@L["The session has been paused."]

+ +
+
+

@L["Could not resume the session."]

+ +
+
+ +@code { + private string[] _retrying = []; + + // Blazor writes the countdown into the span, so the sentence is split where the translation puts the number. + protected override void OnInitialized() => _retrying = L["Rejoin failed. Trying again in {0} seconds."].Value.Split("{0}", 2); +} diff --git a/src/FluentCMS.Admin/Components/Layout/ThemeToggle.razor b/src/FluentCMS.Admin/Components/Layout/ThemeToggle.razor new file mode 100644 index 000000000..e168b81c6 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Layout/ThemeToggle.razor @@ -0,0 +1,20 @@ +@inject IStringLocalizer L + +@* shadcn's mode toggle. admin.js applies the choice to the root element and keeps it in the theme cookie, so it works + on static pages and while the circuit is down; "system" clears the choice. *@ + + + + + @L["Toggle theme"] + + + @L["Light"] + @L["Dark"] + @L["System"] + + + +@code { + [Parameter] public string? Class { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Pages/Showcase.razor b/src/FluentCMS.Admin/Components/Pages/Showcase.razor new file mode 100644 index 000000000..d4806ec5c --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/Showcase.razor @@ -0,0 +1,201 @@ +@page "/ui" +@rendermode InteractiveServer +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@L["Components"] + +@* Every component in Components/Ui with its keyboard hints (ADR-0011): the contributors' reference and the visual regression target. + A new component is added here in the same change; a test checks that none is missing. *@ +
+
+

@L["Components"]

+

@L["The admin's own components and their keys. The header switches the theme; the link switches the direction."]

+
+ + @(IsRtl ? L["Left to right"] : L["Right to left"]) + +
+ +

+ @L["Last action on the server:"] + @(_lastAction ?? L["none"]) +

+ +
+
+

@L["Button"]

+
+ + + + + + +
+
+ + + + + +
+ @Hints(([L["Tab"]], L["Moves focus to the button."]), ([L["Enter"], L["Space"]], L["Activates it."])) +
+ +
+

@L["Badge"]

+
+ @L["Default"] + @L["Secondary"] + @L["Destructive"] + @L["Outline"] +
+ @Hints(([L["Tab"]], L["Passes over it; a badge labels, it takes no focus."])) +
+ +
+

@L["Input"]

+
+ + + +
+ @Hints(([L["Tab"]], L["Moves focus to the field."])) +
+ +
+

@L["FormField"]

+
+ + + + + + +
+ @Hints(([L["Tab"]], L["Moves focus to the field; its description and error are read with it."])) +
+ +
+

@L["Card"]

+ + + @L["Card title"] + @L["A short description of what the card holds."] + + +

@L["Content goes here."]

+
+
+ @Hints(([L["Tab"]], L["Passes through to the controls inside; the card itself takes no focus."])) +
+ +
+

@L["Dialog"]

+ + @L["Edit profile"] + + + @L["Edit profile"] + @L["Make changes to your profile here. Save when you're done."] + +
+ + + + + + +
+ + @L["Cancel"] + @L["Save changes"] + +
+
+ @Hints( + ([L["Enter"], L["Space"]], L["Opens the dialog and moves focus into it."]), + ([L["Tab"], L["Shift"] + "+" + L["Tab"]], L["Moves between its controls and wraps around."]), + ([L["Esc"]], L["Closes it and returns focus to the trigger."])) +
+ +
+ + + @L["Open menu"] + + @L["My account"] + + + @L["Profile"]⇧⌘P + @L["Billing"]⌘B + @L["Settings"]⌘S + @L["Keyboard shortcuts"] + + + @L["Log out"]⇧⌘Q + + + @Hints( + ([L["Enter"], L["Space"], "↓"], L["Opens the menu on its first item."]), + (["↑"], L["Opens it on its last item."]), + (["↓", "↑", L["Home"], L["End"]], L["Move between items, skipping disabled ones."]), + ([L["A–Z"]], L["Moves to the next item starting with that letter."]), + ([L["Enter"]], L["Chooses the item and closes the menu."]), + ([L["Esc"], L["Tab"]], L["Close the menu; Esc returns focus to the trigger."])) +
+ +
+

@L["Command"]

+ + + + @L["No results found."] + + @L["Calendar"] + @L["Search emoji"] + @L["Calculator"] + + + + @L["Profile"]⌘P + @L["Billing"]⌘B + @L["Settings"]⌘S + + + + @Hints( + ([L["Type"]], L["Filters the options in the browser, with no round trip."]), + (["↓", "↑", L["Home"], L["End"]], L["Move the active option, skipping disabled ones."]), + ([L["Enter"]], L["Chooses the active option."])) +
+
+ +@code { + private string? _lastAction; + + [SupplyParameterFromQuery(Name = "dir")] + private string? Dir { get; set; } + + private bool IsRtl => Dir == "rtl"; + + private void Record(string action) => _lastAction = L["{0} at {1}", action, DateTime.Now.ToString("HH:mm:ss")]; + + // shadcn's Kbd classes on a plain element; the Kbd component arrives with the first screen that needs it. + private RenderFragment Hints(params (string[] Keys, string Action)[] hints) => + @
+ @foreach (var (keys, action) in hints) + { +
+
+ @foreach (var key in keys) + { + @key + } +
+
@action
+
+ } +
; +} diff --git a/src/FluentCMS.Admin/Components/Pages/UiSpike.razor b/src/FluentCMS.Admin/Components/Pages/UiSpike.razor deleted file mode 100644 index d88a5a722..000000000 --- a/src/FluentCMS.Admin/Components/Pages/UiSpike.razor +++ /dev/null @@ -1,122 +0,0 @@ -@page "/ui-spike" -@rendermode InteractiveServer -@inject AdminOptions Options -@inject NavigationManager Navigation -@inject IStringLocalizer L - -@L["UI spike"] - -@if (!Options.ShowcaseEnabled) -{ - return; -} - -
-
-

@L["UI spike"]

-

@L["Dialog, DropdownMenu and Command on the admin's own primitives (ADR-0011). The showcase grows from here."]

-
- -
- -

- @L["Last action on the server:"] - @_lastAction - (@L["render"] @_renders) -

- -
-
-

@L["Primitives"]

- -
-

Dialog

- - @L["Edit profile"] - - - @L["Edit profile"] - @L["Make changes to your profile here. Click save when you're done."] - -
- - - - -
- - @L["Cancel"] - @L["Save changes"] - -
-
-
- -
-

DropdownMenu

- - @L["Open menu"] - - @L["My account"] - - - @L["Profile"]⇧⌘P - @L["Billing"]⌘B - @L["Settings"]⌘S - @L["Keyboard shortcuts"] - - - @L["Log out"]⇧⌘Q - - -
- -
-

Command

- - - - @L["No results found."] - - @L["Calendar"] - @L["Search emoji"] - @L["Calculator"] - - - - @L["Profile"]⌘P - @L["Billing"]⌘B - @L["Settings"]⌘S - - - -
-
- -
- -@code { - private string _lastAction = "none"; - private int _renders; - - private string Theme => Query("theme") ?? "light"; - private string Dir => Query("dir") ?? "ltr"; - - // Outside Development the route answers 404 through the shell's NotFound page. - protected override void OnInitialized() - { - if (!Options.ShowcaseEnabled) - { - Navigation.NotFound(); - } - } - - protected override void OnAfterRender(bool firstRender) => _renders++; - - private void Record(string action) => _lastAction = $"{action} at {DateTime.Now:HH:mm:ss.fff}"; - - private string? Query(string name) => System.Web.HttpUtility.ParseQueryString(Navigation.ToAbsoluteUri(Navigation.Uri).Query)[name]; -} diff --git a/src/FluentCMS.Admin/Styles/app.css b/src/FluentCMS.Admin/Styles/app.css index 970f1d2db..05d5f1074 100644 --- a/src/FluentCMS.Admin/Styles/app.css +++ b/src/FluentCMS.Admin/Styles/app.css @@ -2,7 +2,17 @@ @import "tailwindcss" source(none); @source "../Components"; -@custom-variant dark (&:is(.dark *)); +/* Dark with the dark class on the root, or with no stored choice (neither class) when the system prefers it. */ +@custom-variant dark { + &:where(.dark, .dark *) { + @slot; + } + @media (prefers-color-scheme: dark) { + &:where(:root:not(.light), :root:not(.light) *) { + @slot; + } + } +} /* shadcn tokens, ported verbatim from the shadcn "neutral" theme for Tailwind v4. */ @theme inline { @@ -45,6 +55,7 @@ } :root { + color-scheme: light; --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); @@ -79,38 +90,42 @@ --sidebar-ring: oklch(0.708 0 0); } -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); +/* The dark tokens follow the dark variant above, so the class and the system preference switch them the same way. */ +:root { + @variant dark { + color-scheme: dark; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + } } @layer base { @@ -146,4 +161,22 @@ position-area: block-end span-inline-end; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; } + + /* Blazor reports a lost circuit as a state class on this element (ReconnectModal.razor); each part names the state it shows in. */ + #components-reconnect-modal { + display: none; + &:is(.components-reconnect-show, .components-reconnect-failed, .components-reconnect-paused, .components-reconnect-resume-failed) { + display: flex; + } + & [data-reconnect-when] { + display: none; + } + &.components-reconnect-show:not(.components-reconnect-retrying) [data-reconnect-when="show"], + &.components-reconnect-retrying [data-reconnect-when="retrying"], + &.components-reconnect-failed [data-reconnect-when="failed"], + &.components-reconnect-paused [data-reconnect-when="paused"], + &.components-reconnect-resume-failed [data-reconnect-when="resume-failed"] { + display: block; + } + } } diff --git a/src/FluentCMS.Admin/ThemePreference.cs b/src/FluentCMS.Admin/ThemePreference.cs new file mode 100644 index 000000000..1f25567b5 --- /dev/null +++ b/src/FluentCMS.Admin/ThemePreference.cs @@ -0,0 +1,16 @@ +namespace FluentCMS.Admin; + +// The theme picked with the header toggle: "light", "dark", or null to follow the system preference. +// The host reads it from the request, so the root element has its class before anything runs in the browser. +public interface IThemePreference +{ + string? Current { get; } +} + +// admin.js keeps the choice in this cookie; any other value is no choice. +public static class ThemeCookie +{ + public const string Name = "fluentcms-theme"; + + public static string? Parse(string? value) => value is "light" or "dark" ? value : null; +} diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 3c2854056..b854422f5 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -1,6 +1,6 @@ -// The admin's only JavaScript (ADR-0011). It installs delegated behaviour for the native overlays -// once per document: no component makes an interop call to work, nothing has to be re-attached -// after Blazor re-renders, and everything here keeps working while the circuit is disconnected. +// The admin's only JavaScript (ADR-0011). It installs delegated behaviour for the native overlays, the theme +// and the reconnect display once per document: no component makes an interop call to work, nothing has to be +// re-attached after Blazor re-renders, and everything here keeps working while the circuit is disconnected. const supportsCommands = "command" in HTMLButtonElement.prototype; const supportsAnchors = CSS.supports("position-area: block-end"); @@ -118,7 +118,8 @@ function onMenuTriggerKeydown(event, trigger) { event.preventDefault(); menu.dataset.openFocus = event.key === "ArrowUp" ? "last" : "first"; if (menu.matches(":popover-open")) focusMenuItem(menu, event.key === "ArrowUp" ? -1 : 0); - else menu.showPopover(); + // The source makes the trigger the menu's anchor, as opening through popovertarget does. + else menu.showPopover({ source: trigger }); } // --------------------------------------------------------------------------------------------- @@ -147,7 +148,15 @@ function selectOption(input, list, index) { options[next].setAttribute("data-selected", "true"); options[next].setAttribute("aria-selected", "true"); input.setAttribute("aria-activedescendant", options[next].id); - options[next].scrollIntoView({ block: "nearest" }); + revealOption(list, options[next]); +} + +// Scrolls the list only: scrollIntoView would also scroll the page, which jumps to a palette further down on load. +function revealOption(list, option) { + const bounds = list.getBoundingClientRect(); + const rect = option.getBoundingClientRect(); + if (rect.top < bounds.top) list.scrollTop -= bounds.top - rect.top; + else if (rect.bottom > bounds.bottom) list.scrollTop += rect.bottom - bounds.bottom; } function filterCommand(input) { @@ -200,6 +209,70 @@ const observer = new MutationObserver((mutations) => { inputs.forEach(filterCommand); }); +// --------------------------------------------------------------------------------------------- +// Theme: the root carries "light" or "dark", or neither to follow the system. The choice goes into +// the cookie the host reads (ThemePreference.cs), so the next page arrives with the class already set. + +const themeCookie = "fluentcms-theme"; + +function chooseTheme(theme) { + const stored = theme === "light" || theme === "dark"; + document.documentElement.classList.remove("light", "dark"); + if (stored) document.documentElement.classList.add(theme); + const secure = location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${themeCookie}=${stored ? theme : ""}; path=/; max-age=${stored ? 31536000 : 0}; samesite=lax${secure}`; +} + +// --------------------------------------------------------------------------------------------- +// Reconnect: Blazor reports its state as a class on #components-reconnect-modal (ReconnectModal.razor) +// and leaves the buttons, and the reload after a rejected circuit, to the page, as its default display does. + +const reconnectStates = ["show", "hide", "retrying", "failed", "rejected", "paused", "resume-failed"].map((state) => `components-reconnect-${state}`); + +function showReconnectState(state) { + const modal = document.getElementById("components-reconnect-modal"); + modal?.classList.remove(...reconnectStates); + modal?.classList.add(`components-reconnect-${state}`); +} + +async function retryConnection() { + document.removeEventListener("visibilitychange", retryWhenVisible); + showReconnectState("show"); + try { + if (await Blazor.reconnect() || await Blazor.resumeCircuit()) showReconnectState("hide"); + else location.reload(); + } catch { + showReconnectState("failed"); + document.addEventListener("visibilitychange", retryWhenVisible); + } +} + +async function resumeConnection() { + try { + if (await Blazor.resumeCircuit()) showReconnectState("hide"); + else location.reload(); + } catch { + showReconnectState("resume-failed"); + } +} + +function retryWhenVisible() { + if (document.visibilityState === "visible") retryConnection(); +} + +function onReconnectStateChanged(event) { + if (event.detail?.state === "rejected") location.reload(); + if (event.detail?.state === "failed") document.addEventListener("visibilitychange", retryWhenVisible); +} + +function onReconnectClick(button) { + switch (button.dataset.reconnect) { + case "retry": retryConnection(); break; + case "resume": resumeConnection(); break; + case "reload": location.reload(); break; + } +} + // --------------------------------------------------------------------------------------------- document.addEventListener("click", (event) => { @@ -209,6 +282,12 @@ document.addEventListener("click", (event) => { const item = event.target.closest?.('[data-ui="menu-item"]'); if (item && !item.hasAttribute("data-disabled")) item.closest('[data-ui="menu"]')?.hidePopover(); + const theme = event.target.closest?.("[data-theme-choice]"); + if (theme) chooseTheme(theme.dataset.themeChoice); + + const reconnect = event.target.closest?.("[data-reconnect]"); + if (reconnect) onReconnectClick(reconnect); + closeOnBackdropClick(event); }); @@ -234,5 +313,6 @@ document.addEventListener("input", (event) => { document.addEventListener("pointermove", onCommandPointerMove); document.addEventListener("toggle", onMenuToggle, true); +document.addEventListener("components-reconnect-state-changed", onReconnectStateChanged, true); document.querySelectorAll('[data-ui="command-input"]').forEach(filterCommand); observer.observe(document.body, { childList: true, subtree: true }); diff --git a/src/FluentCMS.Host/CookieThemePreference.cs b/src/FluentCMS.Host/CookieThemePreference.cs new file mode 100644 index 000000000..695f5729c --- /dev/null +++ b/src/FluentCMS.Host/CookieThemePreference.cs @@ -0,0 +1,8 @@ +using FluentCMS.Admin; + +namespace FluentCMS.Host; + +internal sealed class CookieThemePreference(IHttpContextAccessor accessor) : IThemePreference +{ + public string? Current => ThemeCookie.Parse(accessor.HttpContext?.Request.Cookies[ThemeCookie.Name]); +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 6cb020bd5..a08fcae08 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -44,6 +44,7 @@ public static WebApplication BuildWebApplication(string[] args, Action(); + builder.Services.AddScoped(); var provider = AddCookieSignIn(builder.Services, builder.Configuration, builder.Environment); builder.Services.AddSignInRateLimit(builder.Configuration); builder.Services.AddHealthChecks(); @@ -146,7 +147,7 @@ private static void AddFluentCms(IServiceCollection services, IConfiguration con services.AddFluentCmsInfrastructure(configuration); services.AddFluentCmsMcp(); services.AddFluentCmsApi(); - services.AddFluentCmsAdmin(options => options.ShowcaseEnabled = environment.IsDevelopment()); + services.AddFluentCmsAdmin(); // Both modes share one key ring, so whatever the web host protects the stdio host can read. var keyRing = configuration.GetSection(KeyRingOptions.SectionName).Get() ?? new KeyRingOptions(); diff --git a/src/FluentCMS.Host/Security/SecurityHeaders.cs b/src/FluentCMS.Host/Security/SecurityHeaders.cs index a20ecf474..a47beda3e 100644 --- a/src/FluentCMS.Host/Security/SecurityHeaders.cs +++ b/src/FluentCMS.Host/Security/SecurityHeaders.cs @@ -3,7 +3,7 @@ namespace FluentCMS.Host.Security; internal static class SecurityHeaders { // Everything from this origin and nothing inline: Blazor's script, the admin's module and the stylesheet are files (SEC-07). - // Blazor's default reconnect display injects a style element, which this blocks; it still works, unstyled, until the shell brings its own. + // The shell brings its own reconnect display, so Blazor's default one, which injects a style element, never runs. // Browsers also hold the redirect that leaves a form post to form-action, so an external provider's origin is added there. public static string ContentSecurityPolicy(IEnumerable formTargets) { diff --git a/tests/FluentCMS.IntegrationTests/HostTests.cs b/tests/FluentCMS.IntegrationTests/HostTests.cs index 3a454a9d1..93b3a30d9 100644 --- a/tests/FluentCMS.IntegrationTests/HostTests.cs +++ b/tests/FluentCMS.IntegrationTests/HostTests.cs @@ -1,4 +1,5 @@ using System.Net; +using FluentCMS.Admin; using FluentCMS.Host; using FluentCMS.Host.Jobs; using Microsoft.AspNetCore.Hosting; @@ -38,38 +39,71 @@ public async Task Web_host_answers_the_health_endpoint_and_runs_the_job_queue() } [Fact] - public async Task Web_host_serves_the_admin_shell_and_the_showcase_only_in_development() + public async Task Web_host_serves_the_admin_shell_with_the_theme_toggle_and_the_reconnect_display() { using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"]); + await using var host = await AdminHost.StartAsync(install); await host.CreateFirstAdministratorAsync(); await host.SignInAsync(AdminHost.Owner); var home = await host.Client.GetAsync("/"); var homeHtml = await home.Content.ReadAsStringAsync(); - var rtl = await host.Client.GetStringAsync("/?dir=rtl&theme=dark"); - var showcase = await host.Client.GetAsync("/ui-spike"); + var rtl = await host.Client.GetStringAsync("/?dir=rtl"); Assert.Equal(HttpStatusCode.OK, home.StatusCode); - Assert.Contains("", homeHtml); Assert.Contains("_content/FluentCMS.Admin/css/app.generated", homeHtml); Assert.Contains("_framework/blazor.web", homeHtml); - Assert.Contains("", rtl); } - [Fact] - public async Task Showcase_is_not_found_outside_development() + [Theory] + [InlineData("dark", " class=\"dark\"")] + [InlineData("light", " class=\"light\"")] + [InlineData("blue", "")] + [InlineData(null, "")] + public async Task The_root_element_carries_the_stored_theme_and_otherwise_none_so_the_system_preference_applies(string? stored, string rootClass) { using var install = new TemporaryInstall(); await using var host = await AdminHost.StartAsync(install); await host.CreateFirstAdministratorAsync(); await host.SignInAsync(AdminHost.Owner); + if (stored is not null) + { + host.Cookies.Add(host.Client.BaseAddress!, new Cookie(ThemeCookie.Name, stored)); + } + + var home = await host.Client.GetStringAsync("/"); + var signIn = await host.Client.GetStringAsync("/sign-in"); + + Assert.Contains($"", home); + Assert.Contains($"", signIn); + } - var showcase = await host.Client.GetAsync("/ui-spike"); + [Fact] + public async Task The_showcase_is_for_signed_in_users_in_every_environment_and_replaces_the_spike_page() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var anonymous = await host.Client.GetAsync("/ui"); + await host.SignInAsync(AdminHost.Owner); + var showcase = await host.Client.GetAsync("/ui"); + var html = await showcase.Content.ReadAsStringAsync(); + var spike = await host.Client.GetAsync("/ui-spike"); - Assert.Equal(HttpStatusCode.NotFound, showcase.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, anonymous.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2Fui", host.LocationOf(anonymous)); + Assert.Equal(HttpStatusCode.OK, showcase.StatusCode); + Assert.Contains("data-slot=\"dialog-content\"", html); + Assert.Contains("role=\"menu\"", html); + Assert.Contains("role=\"combobox\"", html); + Assert.Contains("Owner", await host.Client.GetStringAsync("/")); diff --git a/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs index 35728b96b..b6d5de7a8 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs @@ -15,9 +15,9 @@ public sealed class SignInTests public async Task A_fresh_install_leads_every_admin_route_to_setup_until_the_first_administrator_exists() { using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"]); + await using var host = await AdminHost.StartAsync(install); - foreach (var route in new[] { "/", "/ui-spike", "/sign-in", "/sign-out" }) + foreach (var route in new[] { "/", "/ui", "/sign-in", "/sign-out" }) { var (path, response) = await host.FollowAsync(route); Assert.Equal("/setup", path); @@ -83,18 +83,18 @@ public async Task Setup_reports_problems_next_to_the_fields_and_creates_nobody() public async Task An_anonymous_request_is_sent_to_sign_in_and_returns_to_the_route_it_asked_for() { using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"]); + await using var host = await AdminHost.StartAsync(install); await host.CreateFirstAdministratorAsync(); - var anonymous = await host.Client.GetAsync("/ui-spike"); + var anonymous = await host.Client.GetAsync("/ui"); Assert.Equal(HttpStatusCode.Redirect, anonymous.StatusCode); - Assert.Equal("/sign-in?ReturnUrl=%2Fui-spike", host.LocationOf(anonymous)); + Assert.Equal("/sign-in?ReturnUrl=%2Fui", host.LocationOf(anonymous)); var signedIn = await host.SignInAsync(AdminHost.Owner, host.LocationOf(anonymous)!); Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); - Assert.Equal("/ui-spike", host.LocationOf(signedIn)); - Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/ui-spike")).StatusCode); + Assert.Equal("/ui", host.LocationOf(signedIn)); + Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/ui")).StatusCode); } [Theory] diff --git a/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs b/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs index 943fa22b1..136e73518 100644 --- a/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs +++ b/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs @@ -87,10 +87,35 @@ public async Task Every_response_carries_the_content_security_policy_and_the_sig Assert.Contains("script-src 'self'", policy); Assert.Contains("frame-ancestors 'none'", policy); Assert.Contains("object-src 'none'", policy); + Assert.Contains("style-src 'self'", policy); + Assert.DoesNotContain("unsafe-inline", policy); + Assert.DoesNotContain("unsafe-eval", policy); Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options"))); }); } + // The policy allows no inline script or style, so the shell's pages must not need any: the theme comes from a cookie + // the server reads, and the reconnect display is markup that the stylesheet shows per state. + [Fact] + public async Task Admin_pages_carry_no_inline_script_or_style() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + var signIn = await host.Client.GetStringAsync("/sign-in"); + await host.SignInAsync(AdminHost.Owner); + host.Cookies.Add(host.Client.BaseAddress!, new Cookie(FluentCMS.Admin.ThemeCookie.Name, "dark")); + + var pages = new[] { signIn, await host.Client.GetStringAsync("/"), await host.Client.GetStringAsync("/ui") }; + + Assert.All(pages, html => + { + Assert.DoesNotMatch(InlineScript(), html); + Assert.DoesNotMatch(InlineStyle(), html); + Assert.Contains("id=\"components-reconnect-modal\"", html); + }); + } + [Fact] public async Task A_cookie_issued_before_a_restart_is_still_valid_after_it() { @@ -115,4 +140,11 @@ public async Task A_cookie_issued_before_a_restart_is_still_valid_after_it() [GeneratedRegex("(?:href|src)=\"(_(?:content|framework)/[^\"]+)\"")] private static partial Regex AssetReference(); + + // A script element without a source, or an event handler attribute. + [GeneratedRegex("]*\\ssrc=)[^>]*>|\\son[a-z]+=", RegexOptions.IgnoreCase)] + private static partial Regex InlineScript(); + + [GeneratedRegex("]|\\sstyle=", RegexOptions.IgnoreCase)] + private static partial Regex InlineStyle(); } diff --git a/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs b/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs index 45bb0f05a..95f7a7d9e 100644 --- a/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs +++ b/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs @@ -6,7 +6,7 @@ public sealed class AdminRoutesTests { [Theory] [InlineData("/")] - [InlineData("/ui-spike")] + [InlineData("/ui")] [InlineData("/entries?type=page&page=2")] public void A_path_on_this_site_is_local(string url) { diff --git a/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs b/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs new file mode 100644 index 000000000..d2e0da186 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs @@ -0,0 +1,63 @@ +using Bunit; +using FluentCMS.Admin.Components.Layout; + +namespace FluentCMS.Tests.Admin; + +public sealed class ReconnectModalTests +{ + [Fact] + public void Renders_the_element_Blazor_looks_for_with_a_part_for_each_reconnect_state() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + var modal = cut.Find("#components-reconnect-modal"); + var parts = cut.FindAll("[data-reconnect-when]"); + + Assert.Equal("status", modal.GetAttribute("role")); + Assert.Equal("polite", modal.GetAttribute("aria-live")); + Assert.True(modal.HasAttribute("data-permanent")); + Assert.Equal(["show", "retrying", "failed", "paused", "resume-failed"], parts.Select(part => part.GetAttribute("data-reconnect-when"))); + Assert.Equal("Connection lost. Rejoining the server…", parts[0].TextContent); + } + + [Fact] + public void The_countdown_sits_where_the_sentence_puts_it() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + var retrying = cut.Find("[data-reconnect-when=retrying]"); + + Assert.Equal("components-seconds-to-next-attempt", retrying.QuerySelector("span")!.Id); + Assert.Equal("Rejoin failed. Trying again in seconds.", retrying.InnerHtml); + } + + [Fact] + public void Each_failure_offers_its_button_for_admin_js() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + Assert.Equal("retry", cut.Find("[data-reconnect-when=failed] button").GetAttribute("data-reconnect")); + Assert.Equal("resume", cut.Find("[data-reconnect-when=paused] button").GetAttribute("data-reconnect")); + Assert.Equal("reload", cut.Find("[data-reconnect-when=resume-failed] button").GetAttribute("data-reconnect")); + Assert.All(cut.FindAll("button"), button => Assert.Equal("button", button.GetAttribute("type"))); + } + + [Fact] + public void Needs_no_inline_style_or_script_so_the_content_security_policy_holds() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + // Visibility per state comes from app.css, so the element and its parts carry no display utility that would override it. + Assert.Empty(cut.FindAll("[style], script, style")); + Assert.All(cut.FindAll("#components-reconnect-modal, [data-reconnect-when]"), element => + Assert.DoesNotContain(element.ClassList, name => name is "hidden" or "block" or "flex" or "grid" or "inline-flex" or "contents")); + } +} diff --git a/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs b/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs new file mode 100644 index 000000000..25a719825 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs @@ -0,0 +1,74 @@ +using Bunit; +using FluentCMS.Admin.Components.Pages; +using FluentCMS.Admin.Components.Ui; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Admin; + +public sealed class ShowcaseTests +{ + [Fact] + public void Renders_every_component_in_the_ui_folder() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + var components = typeof(Button).Assembly.GetTypes() + .Where(type => type.Namespace == typeof(Button).Namespace && typeof(IComponent).IsAssignableFrom(type) && !type.IsAbstract) + .Select(type => type.Name) + .ToHashSet(); + var rendered = cut.FindComponents().Select(component => component.Instance.GetType().Name).ToHashSet(); + + Assert.NotEmpty(components); + Assert.Subset(rendered, components); + } + + [Fact] + public void Every_section_lists_its_keyboard_hints() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + var sections = cut.FindAll("section"); + + Assert.Equal(8, sections.Count); + Assert.All(sections, section => + { + var hints = section.QuerySelector("[data-testid=keyboard-hints]"); + Assert.NotNull(hints); + Assert.NotEmpty(hints.QuerySelectorAll("dt kbd")); + Assert.NotEmpty(hints.QuerySelectorAll("dd")); + }); + } + + [Fact] + public async Task An_action_reports_on_the_server_status_line() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + Assert.Equal("none", cut.Find("[data-testid=last-action]").TextContent); + + await cut.Find("[data-slot=button][data-variant=secondary]").ClickAsync(new()); + + Assert.StartsWith("Secondary at ", cut.Find("[data-testid=last-action]").TextContent); + } + + [Fact] + public void The_direction_link_switches_to_right_to_left_and_back() + { + using var context = new AdminTestContext(); + var navigation = context.Services.GetRequiredService(); + + var cut = context.Render(); + Assert.EndsWith("?dir=rtl", cut.Find("[data-testid=direction]").GetAttribute("href")); + + navigation.NavigateTo("ui?dir=rtl"); + cut = context.Render(); + + Assert.DoesNotContain("dir=", cut.Find("[data-testid=direction]").GetAttribute("href")); + } +} diff --git a/tests/FluentCMS.Tests/Admin/StylesheetTests.cs b/tests/FluentCMS.Tests/Admin/StylesheetTests.cs new file mode 100644 index 000000000..5986cc291 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/StylesheetTests.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace FluentCMS.Tests.Admin; + +// Reads the stylesheet the build compiles with Tailwind, the one the host serves, with whitespace and quotes removed. +public sealed class StylesheetTests +{ + private static readonly string Css = Regex.Replace( + File.ReadAllText(Path.Combine(ProjectReferenceRulesTests.FindRepositoryRoot(), "src", "FluentCMS.Admin", "wwwroot", "css", "app.generated.css")), + "[\\s\"']", + string.Empty); + + [Fact] + public void Dark_applies_with_the_dark_class_or_when_nothing_is_stored_and_the_system_prefers_it() + { + Assert.Contains(":root{color-scheme:light;", Css); + Assert.Contains(":root:where(.dark,.dark*){color-scheme:dark;", Css); + Assert.Contains("@media(prefers-color-scheme:dark){:root:where(:root:not(.light),:root:not(.light)*){color-scheme:dark;", Css); + } + + [Fact] + public void The_reconnect_display_is_hidden_until_Blazor_reports_a_state_and_then_shows_only_that_part() + { + Assert.Contains("#components-reconnect-modal{display:none}", Css); + Assert.Contains("#components-reconnect-modal[data-reconnect-when]{display:none}", Css); + Assert.Contains("#components-reconnect-modal.components-reconnect-show:not(.components-reconnect-retrying)[data-reconnect-when=show]", Css); + Assert.Contains("#components-reconnect-modal.components-reconnect-failed[data-reconnect-when=failed]", Css); + Assert.Contains("#components-reconnect-modal.components-reconnect-resume-failed[data-reconnect-when=resume-failed]", Css); + } +} diff --git a/tests/FluentCMS.Tests/Admin/ThemeToggleTests.cs b/tests/FluentCMS.Tests/Admin/ThemeToggleTests.cs new file mode 100644 index 000000000..df7abf901 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/ThemeToggleTests.cs @@ -0,0 +1,50 @@ +using Bunit; +using FluentCMS.Admin.Components.Layout; + +namespace FluentCMS.Tests.Admin; + +public sealed class ThemeToggleTests +{ + [Fact] + public void Renders_a_named_menu_button_that_offers_light_dark_and_the_system_theme() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + var trigger = cut.Find("[data-testid=theme-toggle]"); + var menu = cut.Find("[role=menu]"); + var items = cut.FindAll("[role=menuitem]"); + + Assert.Equal("BUTTON", trigger.TagName); + Assert.Equal(menu.Id, trigger.GetAttribute("popovertarget")); + Assert.Equal("menu", trigger.GetAttribute("aria-haspopup")); + Assert.Equal("Toggle theme", trigger.QuerySelector(".sr-only")!.TextContent); + Assert.All(trigger.QuerySelectorAll("svg"), icon => Assert.Equal("true", icon.GetAttribute("aria-hidden"))); + Assert.Equal(["light", "dark", "system"], items.Select(item => item.GetAttribute("data-theme-choice"))); + Assert.Equal(["Light", "Dark", "System"], items.Select(item => item.TextContent.Trim())); + } + + [Fact] + public void The_choice_is_left_to_admin_js_with_no_inline_script_or_style() + { + using var context = new AdminTestContext(); + + var cut = context.Render(); + + // admin.js applies data-theme-choice, so the toggle works from the static layout and while the circuit is down. + Assert.All(cut.FindAll("[data-theme-choice]"), item => Assert.Equal("-1", item.GetAttribute("tabindex"))); + Assert.Empty(cut.FindAll("[style], script, style")); + Assert.DoesNotContain(cut.FindAll("*").SelectMany(element => element.Attributes), attribute => attribute.Name.StartsWith("on", StringComparison.Ordinal)); + } + + [Fact] + public void Takes_extra_classes_for_its_place_in_the_header() + { + using var context = new AdminTestContext(); + + var cut = context.Render(toggle => toggle.Add(t => t.Class, "ms-auto")); + + Assert.Contains("ms-auto", cut.Find("[data-testid=theme-toggle]").ClassList); + } +} diff --git a/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs b/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs index 15accb238..f1da3eac3 100644 --- a/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs +++ b/tests/FluentCMS.Tests/ProjectReferenceRulesTests.cs @@ -47,7 +47,7 @@ private static string ProjectName(string include) return Path.GetFileNameWithoutExtension(include.Replace('\\', '/')); } - private static string FindRepositoryRoot() + internal static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); From 84b3a65391198220eb45ad3d3f74fb3a55ffbdd3 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 14:09:22 -0400 Subject: [PATCH 2/2] Show the Table components on /ui They arrived with the user management screen, and the showcase renders every component in Components/Ui. --- .../Components/Pages/Showcase.razor | 26 +++++++++++++++++++ tests/FluentCMS.Tests/Admin/ShowcaseTests.cs | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/FluentCMS.Admin/Components/Pages/Showcase.razor b/src/FluentCMS.Admin/Components/Pages/Showcase.razor index d4806ec5c..7b096d044 100644 --- a/src/FluentCMS.Admin/Components/Pages/Showcase.razor +++ b/src/FluentCMS.Admin/Components/Pages/Showcase.razor @@ -91,6 +91,32 @@ @Hints(([L["Tab"]], L["Passes through to the controls inside; the card itself takes no focus."])) +
+

@L["Table"]

+ + + + @L["Display name"] + @L["Email"] + @L["Status"] + + + + + Owner + owner@@example.com + @L["Enabled"] + + + Editor + editor@@example.com + @L["Disabled"] + + +
+ @Hints(([L["Tab"]], L["Passes through to the controls in the rows; the table itself takes no focus."])) +
+

@L["Dialog"]

diff --git a/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs b/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs index 25a719825..1c73d72ee 100644 --- a/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs +++ b/tests/FluentCMS.Tests/Admin/ShowcaseTests.cs @@ -34,7 +34,7 @@ public void Every_section_lists_its_keyboard_hints() var sections = cut.FindAll("section"); - Assert.Equal(8, sections.Count); + Assert.Equal(9, sections.Count); Assert.All(sections, section => { var hints = section.QuerySelector("[data-testid=keyboard-hints]");