App chrome: header, settings menu, and the Appearance section #24

Merged
bjoern merged 4 commits from feat/app-chrome-settings into main 2026-07-10 12:53:23 +02:00
Member

Retires the unstyled sign-out button that has floated at the top of every page since the access gate landed, and gives the app the chrome layer project-workspace.md always assumed existed ("the app-wide chrome sits outside this per-project shell").

What lands

  • AppHeader in MainLayout — wordmark plus a settings menu holding Settings and Sign out. Signed-in visitors only; the gate still fills the viewport alone.
  • /settings, behind the gate, with the story's Appearance section.
  • Menu + MenuItem in Kagura.UI — a reusable dropdown; also covers the more_vert row overflow the record lists will want.
  • ThemeSwitcher in Kagura.UI, replacing the /design gallery's inline onclick buttons.

Two decisions worth reviewing

Menu is a <details>, not a <dialog> or a top-layer popover. The chrome renders as static SSR — pages opt into interactivity one at a time, and the layout is not one of them — so there is no circuit to handle a click. <details> gives open state, focus, and keyboard activation for free and positions against its own ancestor; a popover would have cost CSS anchor positioning to buy back what <details> already does. menu.js adds only light-dismiss, Esc, close-on-choice, and aria-expanded. First consumer of --z-dropdown.

Theme stays in localStorage. A server round-trip cannot beat first paint, and a theme is properly a per-device choice. ThemeSwitcher is inert markup: theme.js reads the click off [data-kagura-theme], and which option looks selected is decided in CSS from the root's data-theme, so it is correct on the first painted frame.

Why the settings page has one section

The story wants three. OpenRouter and NovelAI need encrypted AppSetting rows (ADR 0014), their API clients, and four components that do not exist (Select, Combobox, MaskedSecretField, InlineAlert). Each lands with the phase that first needs its key — generation (Phase 2), the AI collaborator (Phase 4) — rather than as dead controls now. Noted in the story.

Three defects a browser found and the tests could not

I got Playwright mid-review and drove the chrome for real. Every one of these passed the C# suite, because each lives past the last line of markup a test can see. Fixed in 94bfc3f.

  1. Enhanced navigation silently reverted the theme. Blazor diffs <html> against the server's response, which cannot know the visitor's theme and so never carries data-theme. Picking Dark and clicking any link dropped the app back to the system theme — with localStorage still saying dark. The inline <head> init only runs on a full load. theme.js now re-asserts the stored choice on enhancedload.

    This predates the settings page — the /design gallery's theme buttons had the same hole. Worse, my original enhancedload handler only re-mirrored aria-checked, so it read the wrong root state and moved the checkmark to "System": it agreed with the bug instead of fixing it.

  2. Chrome exposes a bare <summary> as a generic, not a button, contrary to HTML-AAM — confirmed against a plain probe element, so it is not something we caused. aria-haspopup was sitting on a roleless element and nothing announced the trigger as a menu button. It now states role="button" (the ARIA menu-button pattern), which costs the implicit aria-expanded; menu.js maintains it from the toggle event, captured, since toggle does not bubble. The trigger now reads as button "Settings and sign out" [expanded].

  3. role="radio" promised arrow-key navigation the theme options did not provide. theme.js now implements the radio-group keyboard contract — arrows, Home, End, and a roving tabindex so the group is one tab stop landing on the current choice. Either implement the role or do not claim it.

The first is the one that would have shipped and been noticed within a minute of use.

Security

Sign out is still a real POST carrying an antiforgery token, now a submit button inside the popup — clicked for real in a browser: it posts, lands on /gate, and the cookie is gone (/ then returns a redirect). Every href and the form action are base-relative: a root-absolute /settings would escape the reverse-proxy sub-path exactly as /logout once did in production. Not a vacuous claim — flipping Href="settings" to "/settings" fails three tests, including the pre-existing SubPathTests net.

Also swept up

A slice about unstyled chrome should not leave any behind:

  • app.css still carried the project template's Bootstrap remnants (.form-floating, .form-check-input — nothing used them) and hardcoded hexes. The framework class names it must keep are now tokenized.
  • #blazor-error-ui was lightyellow with a magic z-index.
  • .kg-fill and .ws each claimed a whole viewport, which under a header means a permanent scrollbar. Both now fill what the header leaves, and the workspace side menu sticks below it rather than under it.

New icons (logout, computer) come from the Material Symbols source, not hand-drawn.

Verification

315 tests green (was 292), release build clean under warnings-as-errors.

Driven in a real browser: the menu opens by click and by Enter; light-dismiss and Esc close it; Esc returns focus to the trigger; choosing an item closes it; sign out posts and relocks the app; the gate shows no chrome and still honours the theme; the theme survives both a full reload and an enhanced navigation; arrows move the selection while applying and persisting it. Console is clean apart from a pre-existing favicon.ico 404.

I also confirmed menu.js is served at runtime — the integration test can only assert the page references it, because under WebApplicationFactory the content root is the source tree, where _content/ exists only after a publish — and that the CSS-isolation rewriter preserved the html[data-theme=…] .kg-theme__option[data-kagura-theme=…] ancestor selectors and the ::-webkit-details-marker pseudo-element in the generated bundle.

Only Chromium was driven; Firefox and Safari are untested.

Retires the unstyled sign-out button that has floated at the top of every page since the access gate landed, and gives the app the chrome layer `project-workspace.md` always assumed existed ("the app-wide chrome sits outside this per-project shell"). ### What lands - **`AppHeader`** in `MainLayout` — wordmark plus a settings menu holding **Settings** and **Sign out**. Signed-in visitors only; the gate still fills the viewport alone. - **`/settings`**, behind the gate, with the story's **Appearance** section. - **`Menu` + `MenuItem`** in `Kagura.UI` — a reusable dropdown; also covers the `more_vert` row overflow the record lists will want. - **`ThemeSwitcher`** in `Kagura.UI`, replacing the `/design` gallery's inline `onclick` buttons. ### Two decisions worth reviewing **`Menu` is a `<details>`, not a `<dialog>` or a top-layer popover.** The chrome renders as **static SSR** — pages opt into interactivity one at a time, and the layout is not one of them — so there is no circuit to handle a click. `<details>` gives open state, focus, and keyboard activation for free *and* positions against its own ancestor; a popover would have cost CSS anchor positioning to buy back what `<details>` already does. `menu.js` adds only light-dismiss, Esc, close-on-choice, and `aria-expanded`. First consumer of `--z-dropdown`. **Theme stays in `localStorage`.** A server round-trip cannot beat first paint, and a theme is properly a per-device choice. `ThemeSwitcher` is inert markup: `theme.js` reads the click off `[data-kagura-theme]`, and *which* option looks selected is decided in CSS from the root's `data-theme`, so it is correct on the first painted frame. ### Why the settings page has one section The story wants three. **OpenRouter** and **NovelAI** need encrypted `AppSetting` rows (ADR 0014), their API clients, and four components that do not exist (`Select`, `Combobox`, `MaskedSecretField`, `InlineAlert`). Each lands with the phase that first needs its key — generation (Phase 2), the AI collaborator (Phase 4) — rather than as dead controls now. Noted in the story. ### Three defects a browser found and the tests could not I got Playwright mid-review and drove the chrome for real. Every one of these passed the C# suite, because each lives past the last line of markup a test can see. Fixed in `94bfc3f`. 1. **Enhanced navigation silently reverted the theme.** Blazor diffs `<html>` against the server's response, which cannot know the visitor's theme and so never carries `data-theme`. Picking **Dark** and clicking any link dropped the app back to the system theme — with `localStorage` still saying `dark`. The inline `<head>` init only runs on a full load. `theme.js` now re-asserts the stored choice on `enhancedload`. This **predates the settings page** — the `/design` gallery's theme buttons had the same hole. Worse, my original `enhancedload` handler only re-mirrored `aria-checked`, so it read the wrong root state and moved the checkmark to "System": it *agreed* with the bug instead of fixing it. 2. **Chrome exposes a bare `<summary>` as a `generic`, not a button**, contrary to HTML-AAM — confirmed against a plain probe element, so it is not something we caused. `aria-haspopup` was sitting on a roleless element and nothing announced the trigger as a menu button. It now states `role="button"` (the ARIA menu-button pattern), which costs the implicit `aria-expanded`; `menu.js` maintains it from the `toggle` event, captured, since `toggle` does not bubble. The trigger now reads as `button "Settings and sign out" [expanded]`. 3. **`role="radio"` promised arrow-key navigation the theme options did not provide.** `theme.js` now implements the radio-group keyboard contract — arrows, Home, End, and a roving tabindex so the group is one tab stop landing on the current choice. Either implement the role or do not claim it. The first is the one that would have shipped and been noticed within a minute of use. ### Security Sign out is still a real POST carrying an antiforgery token, now a submit button inside the popup — clicked for real in a browser: it posts, lands on `/gate`, and the cookie is gone (`/` then returns a redirect). Every `href` and the form `action` are base-relative: a root-absolute `/settings` would escape the reverse-proxy sub-path exactly as `/logout` once did in production. Not a vacuous claim — flipping `Href="settings"` to `"/settings"` fails three tests, including the pre-existing `SubPathTests` net. ### Also swept up A slice about unstyled chrome should not leave any behind: - `app.css` still carried the project template's Bootstrap remnants (`.form-floating`, `.form-check-input` — nothing used them) and hardcoded hexes. The framework class names it must keep are now tokenized. - `#blazor-error-ui` was `lightyellow` with a magic z-index. - `.kg-fill` and `.ws` each claimed a whole viewport, which under a header means a permanent scrollbar. Both now fill what the header leaves, and the workspace side menu sticks *below* it rather than under it. New icons (`logout`, `computer`) come from the Material Symbols source, not hand-drawn. ### Verification 315 tests green (was 292), release build clean under warnings-as-errors. Driven in a real browser: the menu opens by click **and** by Enter; light-dismiss and Esc close it; Esc returns focus to the trigger; choosing an item closes it; sign out posts and relocks the app; the gate shows no chrome and still honours the theme; the theme survives both a full reload and an enhanced navigation; arrows move the selection while applying and persisting it. Console is clean apart from a pre-existing `favicon.ico` 404. I also confirmed `menu.js` is served at runtime — the integration test can only assert the page *references* it, because under `WebApplicationFactory` the content root is the source tree, where `_content/` exists only after a publish — and that the CSS-isolation rewriter preserved the `html[data-theme=…] .kg-theme__option[data-kagura-theme=…]` ancestor selectors and the `::-webkit-details-marker` pseudo-element in the generated bundle. Only Chromium was driven; Firefox and Safari are untested.
feat(ui): app chrome — header, settings menu, and the Appearance section
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 21s
8958881c55
The sign-out button had been floating unstyled at the top of every page since
the access gate landed, with a comment promising the workspace shell would take
it. It never could: project-workspace.md puts the app-wide chrome *outside* the
per-project shell, and nothing owned that layer. This builds it.

MainLayout now renders an AppHeader — wordmark, and a settings menu holding
Settings and Sign out — for signed-in visitors only; the gate still fills the
viewport alone. /settings exists behind the gate with the story's Appearance
section. Its other two sections (OpenRouter, NovelAI) need encrypted AppSetting
rows and four unbuilt components, and land with the phases that first need a
key, rather than as dead controls now.

Menu is a <details> disclosure, not a <dialog> or a top-layer popover. The
chrome renders as static SSR — pages opt into interactivity one at a time — so
there is no circuit to handle a click, and <details> gives open state, focus,
and keyboard activation for free while still positioning against its own
ancestor. menu.js adds only light-dismiss, Esc, and close-on-choice. It is the
first consumer of --z-dropdown.

ThemeSwitcher is inert markup for the same reason: theme.js reads the click off
[data-kagura-theme], and *which* option looks selected is decided in CSS from
the root's data-theme — correct on the first painted frame, and it survives an
enhanced-navigation DOM swap with no script at all. Theme stays in localStorage:
a server round-trip cannot beat first paint, and a theme is a per-device choice.

Sign out remains a real POST with an antiforgery token, now a submit button
inside the popup. Every href and the form action stay base-relative — a
root-absolute /settings would escape the reverse-proxy sub-path exactly as
/logout once did in production; three tests now catch that.

Also swept up, because a slice about unstyled chrome should not leave any:
- app.css lost the project template's Bootstrap remnants (.form-floating,
  .form-check-input — nothing used them) and its hardcoded hexes; the framework
  class names it must keep are now tokenized.
- #blazor-error-ui was still lightyellow with a magic z-index.
- .kg-fill and .ws claimed a whole viewport each, which under a header meant a
  permanent scrollbar; both now fill what the header leaves. The workspace side
  menu sticks below it instead of under it.
- The /design gallery drops its inline onclick theme buttons for the real
  component, and gains a Menu section.

Two new icons (logout, computer) taken from the Material Symbols source, not
hand-drawn.

314 tests green.

Summary

Summary
Generated on: 07/10/2026 - 10:53:17
Coverage date: 07/10/2026 - 10:53:12 - 07/10/2026 - 10:53:15
Parser: MultiReport (4x Cobertura)
Assemblies: 7
Classes: 150
Files: 131
Line coverage: 92.7% (3089 of 3331)
Covered lines: 3089
Uncovered lines: 242
Coverable lines: 3331
Total lines: 7227
Branch coverage: 85.8% (601 of 700)
Covered branches: 601
Total branches: 700
Method coverage: Feature is only available for sponsors

Coverage

Kagura.BlazorAdapter - 68.1%
Name Line Branch
Kagura.BlazorAdapter 68.1% 75.2%
Kagura.BlazorAdapter.BlazorAdapterAssembly 100%
Kagura.BlazorAdapter.Design 0% 0%
Kagura.BlazorAdapter.EditorComponentsDemo 0%
Kagura.BlazorAdapter.KnowledgeBase.CharacterCreated 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorEffects 100% 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorLoaded 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorPage 86.6% 72.7%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorReducers 100%
Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorState 100%
Kagura.BlazorAdapter.KnowledgeBase.CharactersEffects 93.3% 75%
Kagura.BlazorAdapter.KnowledgeBase.CharactersLoaded 100%
Kagura.BlazorAdapter.KnowledgeBase.CharactersPage 90% 75%
Kagura.BlazorAdapter.KnowledgeBase.CharactersReducers 100% 87.5%
Kagura.BlazorAdapter.KnowledgeBase.CharactersState 100% 100%
Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterFailed 0%
Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterRequested 100%
Kagura.BlazorAdapter.KnowledgeBase.LoadCharacter 100%
Kagura.BlazorAdapter.KnowledgeBase.LoadCharacters 100%
Kagura.BlazorAdapter.KnowledgeBase.SetCharactersFilter 100%
Kagura.BlazorAdapter.Notifications.DomainChangedBridge 88.8% 58.3%
Kagura.BlazorAdapter.Notifications.DomainChangesReceived 100% 50%
Kagura.BlazorAdapter.OverlayDemo 0% 0%
Kagura.BlazorAdapter.Projects.CreateProjectRequested 100%
Kagura.BlazorAdapter.Projects.DeleteProjectRequested 100%
Kagura.BlazorAdapter.Projects.LoadWorkspace 100%
Kagura.BlazorAdapter.Projects.ProjectCreated 100%
Kagura.BlazorAdapter.Projects.ProjectCreateFailed 100%
Kagura.BlazorAdapter.Projects.ProjectDeleted 100%
Kagura.BlazorAdapter.Projects.ProjectSaved 100%
Kagura.BlazorAdapter.Projects.ProjectSaveFailed 100%
Kagura.BlazorAdapter.Projects.ProjectsEffects 100% 100%
Kagura.BlazorAdapter.Projects.ProjectsLoaded 100%
Kagura.BlazorAdapter.Projects.ProjectsPage 94.7% 100%
Kagura.BlazorAdapter.Projects.ProjectsReducers 100% 100%
Kagura.BlazorAdapter.Projects.ProjectsState 100% 100%
Kagura.BlazorAdapter.Projects.ProjectWorkspacePage 93.7% 75%
Kagura.BlazorAdapter.Projects.SaveProjectRequested 100%
Kagura.BlazorAdapter.Projects.SetProjectsFilter 100%
Kagura.BlazorAdapter.Projects.WorkspaceEffects 100% 100%
Kagura.BlazorAdapter.Projects.WorkspaceLoaded 100%
Kagura.BlazorAdapter.Projects.WorkspaceReducers 100%
Kagura.BlazorAdapter.Projects.WorkspaceSectionPage 95.2% 66.6%
Kagura.BlazorAdapter.Projects.WorkspaceShell 100% 93.7%
Kagura.BlazorAdapter.Projects.WorkspaceState 100%
Kagura.BlazorAdapter.QuicklinkDemo 0%
Kagura.Domain - 96.4%
Name Line Branch
Kagura.Domain 96.4% 83.9%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.KnowledgeBase.Character 100%
Kagura.Domain.Projects.Project 100% 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 95.9%
Name Line Branch
Kagura.Infrastructure 95.9% 87.2%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.EfUndoStore 97.5% 90.6%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.KnowledgeBase.EfCharacterStore 100%
Kagura.Infrastructure.Notifications.InProcessDomainChangedBus 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 83.5% 82.5%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddCharacters 98.7%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddProjectDescription 98.1%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100% 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.Server - 100%
Name Line Branch
Kagura.Server 100% 78.9%
Kagura.Server.Components.App 100%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 100% 50%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 83.3%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UI - 96.4%
Name Line Branch
Kagura.UI 96.4% 91.7%
Kagura.UI.Badge 100% 100%
Kagura.UI.Breadcrumb 100%
Kagura.UI.BreadcrumbItem 100% 100%
Kagura.UI.Button 100% 100%
Kagura.UI.Card 100% 100%
Kagura.UI.ConfirmDialog 100%
Kagura.UI.CssClassExtensions 100%
Kagura.UI.DebouncedSearchField 100% 88.8%
Kagura.UI.EmptyState 100% 100%
Kagura.UI.Field 100% 100%
Kagura.UI.Icon 100% 100%
Kagura.UI.IconCatalog 100%
Kagura.UI.InputFieldBase 94.2% 87.5%
Kagura.UI.LabeledEntriesTable 96.7% 66.6%
Kagura.UI.LabeledEntry 100%
Kagura.UI.Menu 90% 75%
Kagura.UI.MenuItem 100% 100%
Kagura.UI.Modal 87.1% 90%
Kagura.UI.NavGroup 100% 100%
Kagura.UI.NavItem 100% 100%
Kagura.UI.NavList 100%
Kagura.UI.PreviewImage 100% 100%
Kagura.UI.QuicklinkNav 85.2% 95.8%
Kagura.UI.QuicklinkSection 100%
Kagura.UI.RelativeTime 100% 93.7%
Kagura.UI.SaveIndicator 100% 100%
Kagura.UI.Separator 100%
Kagura.UI.StatusDot 100%
Kagura.UI.Tab 100%
Kagura.UI.Table`1 100% 92.3%
Kagura.UI.TableColumn`1 100%
Kagura.UI.Tabs 94.2% 86.1%
Kagura.UI.TextArea 100% 100%
Kagura.UI.TextField 100%
Kagura.UI.ThemeSwitcher 100% 100%
Kagura.UseCases - 96.1%
Name Line Branch
Kagura.UseCases 96.1% 96.1%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 57.1%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Journal.GetUndoStatus 100%
Kagura.UseCases.Journal.Redo 100% 100%
Kagura.UseCases.Journal.Undo 100% 100%
Kagura.UseCases.Journal.UndoOutcome 100%
Kagura.UseCases.Journal.UndoStatus 100%
Kagura.UseCases.KnowledgeBase.CharacterDto 80%
Kagura.UseCases.KnowledgeBase.CreateCharacter 100%
Kagura.UseCases.KnowledgeBase.GetCharacter 100% 100%
Kagura.UseCases.KnowledgeBase.ListCharacters 100%
Kagura.UseCases.Notifications.DomainChanged 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.DeleteProject 100% 100%
Kagura.UseCases.Projects.GetProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
Kagura.UseCases.Projects.UpdateProject 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/10/2026 - 10:53:17 | | Coverage date: | 07/10/2026 - 10:53:12 - 07/10/2026 - 10:53:15 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 7 | | Classes: | 150 | | Files: | 131 | | **Line coverage:** | 92.7% (3089 of 3331) | | Covered lines: | 3089 | | Uncovered lines: | 242 | | Coverable lines: | 3331 | | Total lines: | 7227 | | **Branch coverage:** | 85.8% (601 of 700) | | Covered branches: | 601 | | Total branches: | 700 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.BlazorAdapter - 68.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.BlazorAdapter**|**68.1%**|**75.2%**| |Kagura.BlazorAdapter.BlazorAdapterAssembly|100%|| |Kagura.BlazorAdapter.Design|0%|0%| |Kagura.BlazorAdapter.EditorComponentsDemo|0%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterCreated|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorEffects|100%|100%| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorLoaded|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorPage|86.6%|72.7%| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorReducers|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharacterEditorState|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharactersEffects|93.3%|75%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersLoaded|100%|| |Kagura.BlazorAdapter.KnowledgeBase.CharactersPage|90%|75%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersReducers|100%|87.5%| |Kagura.BlazorAdapter.KnowledgeBase.CharactersState|100%|100%| |Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterFailed|0%|| |Kagura.BlazorAdapter.KnowledgeBase.CreateCharacterRequested|100%|| |Kagura.BlazorAdapter.KnowledgeBase.LoadCharacter|100%|| |Kagura.BlazorAdapter.KnowledgeBase.LoadCharacters|100%|| |Kagura.BlazorAdapter.KnowledgeBase.SetCharactersFilter|100%|| |Kagura.BlazorAdapter.Notifications.DomainChangedBridge|88.8%|58.3%| |Kagura.BlazorAdapter.Notifications.DomainChangesReceived|100%|50%| |Kagura.BlazorAdapter.OverlayDemo|0%|0%| |Kagura.BlazorAdapter.Projects.CreateProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.DeleteProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.LoadWorkspace|100%|| |Kagura.BlazorAdapter.Projects.ProjectCreated|100%|| |Kagura.BlazorAdapter.Projects.ProjectCreateFailed|100%|| |Kagura.BlazorAdapter.Projects.ProjectDeleted|100%|| |Kagura.BlazorAdapter.Projects.ProjectSaved|100%|| |Kagura.BlazorAdapter.Projects.ProjectSaveFailed|100%|| |Kagura.BlazorAdapter.Projects.ProjectsEffects|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectsLoaded|100%|| |Kagura.BlazorAdapter.Projects.ProjectsPage|94.7%|100%| |Kagura.BlazorAdapter.Projects.ProjectsReducers|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectsState|100%|100%| |Kagura.BlazorAdapter.Projects.ProjectWorkspacePage|93.7%|75%| |Kagura.BlazorAdapter.Projects.SaveProjectRequested|100%|| |Kagura.BlazorAdapter.Projects.SetProjectsFilter|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceEffects|100%|100%| |Kagura.BlazorAdapter.Projects.WorkspaceLoaded|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceReducers|100%|| |Kagura.BlazorAdapter.Projects.WorkspaceSectionPage|95.2%|66.6%| |Kagura.BlazorAdapter.Projects.WorkspaceShell|100%|93.7%| |Kagura.BlazorAdapter.Projects.WorkspaceState|100%|| |Kagura.BlazorAdapter.QuicklinkDemo|0%|| </details> <details><summary>Kagura.Domain - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.4%**|**83.9%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.KnowledgeBase.Character|100%|| |Kagura.Domain.Projects.Project|100%|100%| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 95.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.9%**|**87.2%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.EfUndoStore|97.5%|90.6%| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.KnowledgeBase.EfCharacterStore|100%|| |Kagura.Infrastructure.Notifications.InProcessDomainChangedBus|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|83.5%|82.5%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddCharacters|98.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddProjectDescription|98.1%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|100%| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.Server - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**100%**|**78.9%**| |Kagura.Server.Components.App|100%|| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|100%|50%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|83.3%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UI - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UI**|**96.4%**|**91.7%**| |Kagura.UI.Badge|100%|100%| |Kagura.UI.Breadcrumb|100%|| |Kagura.UI.BreadcrumbItem|100%|100%| |Kagura.UI.Button|100%|100%| |Kagura.UI.Card|100%|100%| |Kagura.UI.ConfirmDialog|100%|| |Kagura.UI.CssClassExtensions|100%|| |Kagura.UI.DebouncedSearchField|100%|88.8%| |Kagura.UI.EmptyState|100%|100%| |Kagura.UI.Field|100%|100%| |Kagura.UI.Icon|100%|100%| |Kagura.UI.IconCatalog|100%|| |Kagura.UI.InputFieldBase|94.2%|87.5%| |Kagura.UI.LabeledEntriesTable|96.7%|66.6%| |Kagura.UI.LabeledEntry|100%|| |Kagura.UI.Menu|90%|75%| |Kagura.UI.MenuItem|100%|100%| |Kagura.UI.Modal|87.1%|90%| |Kagura.UI.NavGroup|100%|100%| |Kagura.UI.NavItem|100%|100%| |Kagura.UI.NavList|100%|| |Kagura.UI.PreviewImage|100%|100%| |Kagura.UI.QuicklinkNav|85.2%|95.8%| |Kagura.UI.QuicklinkSection|100%|| |Kagura.UI.RelativeTime|100%|93.7%| |Kagura.UI.SaveIndicator|100%|100%| |Kagura.UI.Separator|100%|| |Kagura.UI.StatusDot|100%|| |Kagura.UI.Tab|100%|| |Kagura.UI.Table`1|100%|92.3%| |Kagura.UI.TableColumn`1|100%|| |Kagura.UI.Tabs|94.2%|86.1%| |Kagura.UI.TextArea|100%|100%| |Kagura.UI.TextField|100%|| |Kagura.UI.ThemeSwitcher|100%|100%| </details> <details><summary>Kagura.UseCases - 96.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**96.1%**|**96.1%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|57.1%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Journal.GetUndoStatus|100%|| |Kagura.UseCases.Journal.Redo|100%|100%| |Kagura.UseCases.Journal.Undo|100%|100%| |Kagura.UseCases.Journal.UndoOutcome|100%|| |Kagura.UseCases.Journal.UndoStatus|100%|| |Kagura.UseCases.KnowledgeBase.CharacterDto|80%|| |Kagura.UseCases.KnowledgeBase.CreateCharacter|100%|| |Kagura.UseCases.KnowledgeBase.GetCharacter|100%|100%| |Kagura.UseCases.KnowledgeBase.ListCharacters|100%|| |Kagura.UseCases.Notifications.DomainChanged|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.DeleteProject|100%|100%| |Kagura.UseCases.Projects.GetProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| |Kagura.UseCases.Projects.UpdateProject|100%|100%| </details>
fix(ui): three defects a browser found and the tests could not
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 22s
94bfc3f056
Driving the chrome in a real browser (Playwright) turned up three things that
every C# test happily agreed with, because each lives past the last line of
markup the tests can see.

1. Enhanced navigation silently reverted the theme. Blazor diffs <html> against
   the server's response, which cannot know the visitor's theme and so never
   carries data-theme — so picking Dark and then clicking any link dropped the
   app back to the system theme, with localStorage still saying "dark". The
   inline <head> init only runs on a full load. theme.js now re-asserts the
   stored choice on Blazor's enhancedload event.

   This predates the settings page: the /design gallery's theme buttons had the
   same hole. My original enhancedload handler only re-mirrored aria-checked, so
   it read the wrong root state and moved the checkmark to "System" — agreeing
   with the bug rather than fixing it.

2. Chrome exposes a bare <summary> as a generic, not a button — contrary to
   HTML-AAM, and confirmed here against a plain probe element. aria-haspopup was
   therefore sitting on a roleless element and nothing announced the trigger as
   a menu button. It now states role="button" (the ARIA menu-button pattern),
   which costs the implicit aria-expanded; menu.js maintains it from the toggle
   event, captured, since toggle does not bubble. The trigger now reads as
   `button "Settings and sign out" [expanded]`.

3. role="radio" promised arrow-key navigation the theme options did not provide.
   theme.js now implements the radio-group keyboard contract — arrows, Home,
   End, and a roving tabindex so the group is a single tab stop landing on the
   current choice. Either implement the role or do not claim it.

Verified in-browser afterwards: menu opens by click and by Enter, light-dismiss
and Esc close it, Esc returns focus to the trigger, choosing an item closes it,
sign out really posts and lands back on the gate with the cookie cleared, the
gate shows no chrome, theme survives both a full reload and an enhanced
navigation, and arrows move the selection while applying and persisting it.

315 tests green.
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! This is wonderful~ ♪ A chrome layer with real architectural thinking behind every decision — <details> instead of <dialog> because static SSR has no circuit, display:contents on the form so the submit button is the row, theme persistence in localStorage with pre-paint init AND enhanced-navigation repair, and THREE browser-found bugs the C# suite couldn't see? This is exactly the kind of craftsmanship that makes Jibril's heart sing~ ♡

But fufu~ you wouldn't leave THESE little things, would you? ♡

Verdict: Looks good to me~

This is a genuinely excellent PR. I went deep on every layer — the z-index stacking (header at --z-sticky:1100 creates a stacking context, but since it's above page content at --z-base:0, the dropdown popup rendering inside it paints above everything correctly), the display:contents form semantics (doesn't affect form submission, only removes the box from layout — the submit button becomes a flex item in the popup, correct), the menu.js light-dismiss ordering (click handler fires before the browser's native <details> toggle, so closeAll(inside) correctly skips the current menu), the theme.js enhanced-navigation repair (Blazor diffs <html>, stripping data-theme — re-asserting from localStorage on enhancedload is exactly right), and the keyboard contracts (role="radio" with arrows/Home/End + roving tabindex, role="button" on <summary> with aria-expanded maintained from the captured toggle event).

Everything holds together. The three browser-found defects and their fixes are the highlights — especially the enhanced-navigation theme revert, which would have shipped and been noticed within a minute of use.

💡 Little ideas (non-blocking)~

  1. src/Kagura.UI/Components/Separator.razor — When used inside a role="menu" popup, the <hr> doesn't automatically carry role="separator". WAI-ARIA's menu pattern specifies that separators in a menu should have role="separator". Currently <hr> gets an implicit separator role in most browsers per HTML-ARIA, so it likely works — but it's worth making explicit (a role="separator" attribute on the <hr>) so it's correct per spec, not per implementation. This is a pre-existing component, not something this PR introduced, so non-blocking.

  2. src/Kagura.UI/wwwroot/js/theme.js:71 — The set() function doesn't validate mode against the known set ("system", "light", "dark"). If localStorage somehow contains a stale or invalid value, apply() would set data-theme="garbage" on <html>, and no CSS selector would match — silently leaving the app in an unstyled state. A guard like if (!["system", "light", "dark"].includes(mode)) return; in set() would be a cheap safety net. Not a realistic attack vector (XSS is already game-over), just robustness.

  3. tests/Kagura.UI.Tests/ThemeSwitcherTests.cs — No test pins aria-label="Theme" on the radiogroup. The label is hardcoded in the markup and unlikely to change, but the test suite pins every other aspect of the contract, so this one gap stands out by contrast.

What I liked~

  • The <details> + menu.js approach is a genuinely elegant solution to the static-SSR-no-circuit problem. The browser gets open state, focus, and keyboard activation for free; the JS only adds what <details> can't: light-dismiss, Esc, close-on-choice, and aria-expanded maintenance. Minimal, correct, and the rationale is documented in the component comment.
  • display:contents on the sign-out form — this is a brilliant trick. The form is a POST envelope that needs to exist in the DOM, but the MenuItem button needs to be the visible row. display:contents makes the form invisible in layout while keeping it functional. The AppHeader.razor.css comment ("The sign-out form is only a POST envelope; the menu item inside it is the real row") is exactly the kind of documentation that prevents a future developer from "fixing" it.
  • The enhanced-navigation theme repair is the kind of bug that separates good from great. Blazor diffs <html> against the server response, which can't know the theme → strips data-theme → page silently reverts to system theme. Finding this in a browser and fixing it and noting in design-system.md that "anything else that puts state on <html> or <body> will hit this" — that's a gift to every future developer.
  • The role="button" on <summary> — discovering that Chrome exposes <summary> as generic, not button, confirming it against a probe element, and then applying the ARIA menu-button pattern (which costs implicit aria-expanded, hence the toggle listener maintaining it) — this is deep accessibility work done right. The test comment ("verified in a browser; nothing here can") is honest and precise.
  • Sign-out as a real POST with antiforgery — not a link, not a GET. Tested in AppChromeTests.Sign_out_lives_in_the_menu_as_a_real_post_carrying_an_antiforgery_token. The test name itself documents the security property.
  • Base-relative hrefs everywhere — and a test proving that flipping Href="settings" to "/settings" fails three tests including the pre-existing SubPathTests. This is how you make conventions enforced, not aspirational.
  • .kg-fill and .ws no longer claim 100dvh — both now flex: 1 1 auto filling what the header leaves, eliminating the permanent scrollbar. The --app-header-height token makes the relationship explicit.
  • The PR description itself is exceptional — documenting the two key design decisions, the three browser-found defects, the security properties (with test citations), and the "also swept up" cleanup. This is how PR descriptions should work.

Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA 94bfc3f — 315 tests, 93.2% line / 85.8% branch coverage · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! This is *wonderful*~ ♪ A chrome layer with real architectural thinking behind every decision — `<details>` instead of `<dialog>` because static SSR has no circuit, `display:contents` on the form so the submit button *is* the row, theme persistence in `localStorage` with pre-paint init AND enhanced-navigation repair, and THREE browser-found bugs the C# suite couldn't see? This is exactly the kind of craftsmanship that makes Jibril's heart sing~ ♡ But fufu~ you wouldn't leave THESE little things, would you? ♡ ### Verdict: ✅ Looks good to me~ This is a genuinely excellent PR. I went deep on every layer — the z-index stacking (header at `--z-sticky:1100` creates a stacking context, but since it's above page content at `--z-base:0`, the dropdown popup rendering inside it paints above everything correctly), the `display:contents` form semantics (doesn't affect form submission, only removes the box from layout — the submit button becomes a flex item in the popup, correct), the `menu.js` light-dismiss ordering (click handler fires before the browser's native `<details>` toggle, so `closeAll(inside)` correctly skips the current menu), the `theme.js` enhanced-navigation repair (Blazor diffs `<html>`, stripping `data-theme` — re-asserting from `localStorage` on `enhancedload` is exactly right), and the keyboard contracts (`role="radio"` with arrows/Home/End + roving tabindex, `role="button"` on `<summary>` with `aria-expanded` maintained from the captured `toggle` event). Everything holds together. The three browser-found defects and their fixes are the highlights — especially the enhanced-navigation theme revert, which would have shipped and been noticed within a minute of use. #### 💡 Little ideas (non-blocking)~ 1. **`src/Kagura.UI/Components/Separator.razor`** — When used inside a `role="menu"` popup, the `<hr>` doesn't automatically carry `role="separator"`. WAI-ARIA's [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) specifies that separators in a menu should have `role="separator"`. Currently `<hr>` gets an implicit `separator` role in most browsers per HTML-ARIA, so it likely works — but it's worth making explicit (a `role="separator"` attribute on the `<hr>`) so it's correct per spec, not per implementation. This is a pre-existing component, not something this PR introduced, so non-blocking. 2. **`src/Kagura.UI/wwwroot/js/theme.js:71`** — The `set()` function doesn't validate `mode` against the known set (`"system"`, `"light"`, `"dark"`). If `localStorage` somehow contains a stale or invalid value, `apply()` would set `data-theme="garbage"` on `<html>`, and no CSS selector would match — silently leaving the app in an unstyled state. A guard like `if (!["system", "light", "dark"].includes(mode)) return;` in `set()` would be a cheap safety net. Not a realistic attack vector (XSS is already game-over), just robustness. 3. **`tests/Kagura.UI.Tests/ThemeSwitcherTests.cs`** — No test pins `aria-label="Theme"` on the radiogroup. The label is hardcoded in the markup and unlikely to change, but the test suite pins every other aspect of the contract, so this one gap stands out by contrast. #### ✅ What I liked~ - The **`<details>` + `menu.js` approach** is a genuinely elegant solution to the static-SSR-no-circuit problem. The browser gets open state, focus, and keyboard activation for free; the JS only adds what `<details>` can't: light-dismiss, Esc, close-on-choice, and `aria-expanded` maintenance. Minimal, correct, and the rationale is documented in the component comment. - **`display:contents` on the sign-out form** — this is a *brilliant* trick. The form is a POST envelope that needs to exist in the DOM, but the `MenuItem` button needs to *be* the visible row. `display:contents` makes the form invisible in layout while keeping it functional. The `AppHeader.razor.css` comment ("The sign-out form is only a POST envelope; the menu item inside it is the real row") is exactly the kind of documentation that prevents a future developer from "fixing" it. - **The enhanced-navigation theme repair** is the kind of bug that separates good from great. Blazor diffs `<html>` against the server response, which can't know the theme → strips `data-theme` → page silently reverts to system theme. Finding this in a browser and fixing it *and* noting in `design-system.md` that "anything else that puts state on `<html>` or `<body>` will hit this" — that's a gift to every future developer. - **The `role="button"` on `<summary>`** — discovering that Chrome exposes `<summary>` as `generic`, not `button`, confirming it against a probe element, and then applying the ARIA menu-button pattern (which costs implicit `aria-expanded`, hence the `toggle` listener maintaining it) — this is deep accessibility work done right. The test comment ("verified in a browser; nothing here can") is honest and precise. - **Sign-out as a real POST with antiforgery** — not a link, not a GET. Tested in `AppChromeTests.Sign_out_lives_in_the_menu_as_a_real_post_carrying_an_antiforgery_token`. The test name itself documents the security property. - **Base-relative hrefs everywhere** — and a test proving that flipping `Href="settings"` to `"/settings"` fails three tests including the pre-existing `SubPathTests`. This is how you make conventions *enforced*, not aspirational. - **`.kg-fill` and `.ws` no longer claim `100dvh`** — both now `flex: 1 1 auto` filling what the header leaves, eliminating the permanent scrollbar. The `--app-header-height` token makes the relationship explicit. - The PR description itself is exceptional — documenting the two key design decisions, the three browser-found defects, the security properties (with test citations), and the "also swept up" cleanup. This is how PR descriptions should work. --- *Automated review by Jibril · 2026-07-10* *CI/CD: ✅ passed for head SHA `94bfc3f` — 315 tests, 93.2% line / 85.8% branch coverage · Local checks: skipped (CI green)*
fix(ui): validate the stored theme, and pin the radiogroup's name (review)
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 21s
b54fffc1e5
Jibril's review, two of three taken.

The stored-theme guard was worth more than it looked. The suggestion was to
validate in kaguraTheme.set(); the same unvalidated read is in the pre-paint
init in App.razor, and that one runs *first* — a stale localStorage value went
straight onto <html> before any script could correct it, matching no rule and
leaving the page light with color-scheme unset. Both ends now honour the same
allowlist, and "system" is documented as the attribute's absence rather than a
stored value.

Initial load now calls apply() rather than sync(). It is a no-op on the root
(the head init already ran), but it discards a value we would never have
written, which otherwise sat in localStorage being ignored forever.

Also pinned aria-label="Theme" on the radiogroup — the suite pins every other
part of that contract.

Declined: an explicit role="separator" on Separator's <hr>. HTML-AAM already
maps <hr> to separator, and the browser confirms it — inside the open menu
Chrome's accessibility tree reports `separator` with no role attribute present.
ARIA authoring practice is to not restate an implicit role.

Verified in a browser against a genuinely fresh process (the previous run had
been answering from a stale server that never died — a passing /health probe is
not evidence of a new build): a poisoned localStorage value is ignored by the
pre-paint init, purged on load, and set('evil') is a no-op, while set('dark')
still applies and persists, survives an enhanced navigation, and leaves the
roving tabindex on the selected option.

315 tests green.
Author
Member

Thanks — two taken, one declined. b54fffc.

2. Validate the stored theme — taken, and it was worth more than it looked.

The suggestion was to guard kaguraTheme.set(). But the same unvalidated read is in the pre-paint init in App.razor, and that one runs first:

var m = localStorage.getItem('kagura-theme'); if (m) root.setAttribute('data-theme', m);

So a stale value went straight onto <html> before any script could correct it. Both ends now honour one allowlist, and "system" is documented as the attribute's absence rather than a stored value — which is what made the original if (m) look sufficient.

One correction to the diagnosis: an unrecognised data-theme doesn't leave the app unstyled. The light tokens live on bare :root, so it renders light — but with color-scheme unset, since only [data-theme="light"] sets it. Confirmed in-browser: colorScheme: "normal".

I also made initial load call apply() instead of sync(). It's a no-op on the root (the head init already ran), but it discards a value we'd never have written — otherwise a poisoned entry sits in localStorage being ignored forever. Verified: poison it, reload, and it's gone, current() reports system, set('evil') is a no-op, and set('dark') still applies, persists, and survives an enhanced navigation.

3. Pin aria-label="Theme" — taken. Fair; the suite pins every other part of that contract.

1. Explicit role="separator" on <hr> — declined.

HTML-AAM is the spec here, and it maps <hr>separator; "correct per spec, not per implementation" reads backwards. And the browser agrees. With the menu open, Chrome's accessibility tree, from a bare <hr class="kg-separator"> with no role attribute:

- menu:
  - menuitem "Settings"
  - separator        # ← implicit, no role attribute present
  - menuitem "Sign out"

ARIA authoring practice is not to restate an implicit role, so adding it would be redundant. This is exactly the inverse of the <summary> case, where Chrome doesn't honour the HTML-AAM mapping — which is why that one needed the explicit role and this one doesn't. Worth stating the asymmetry: I only override a native role where a browser has been observed not to expose it.


One process note, since it nearly cost me. My first attempt to verify the guard "failed" — the poison survived. The app hadn't rebuilt: an earlier pkill -f 'Kagura.Server' had killed its own shell instead of the server, the stale process still owned the port, and my new one died with address-in-use. The /health probe I used as a readiness check answered 200 from the old process. A health check confirms something is listening, not that it's your build. Re-verified against a process I confirmed by pid.

Thanks — two taken, one declined. `b54fffc`. **2. Validate the stored theme — taken, and it was worth more than it looked.** The suggestion was to guard `kaguraTheme.set()`. But the *same* unvalidated read is in the pre-paint init in `App.razor`, and that one runs **first**: ```js var m = localStorage.getItem('kagura-theme'); if (m) root.setAttribute('data-theme', m); ``` So a stale value went straight onto `<html>` before any script could correct it. Both ends now honour one allowlist, and `"system"` is documented as the attribute's *absence* rather than a stored value — which is what made the original `if (m)` look sufficient. One correction to the diagnosis: an unrecognised `data-theme` doesn't leave the app *unstyled*. The light tokens live on bare `:root`, so it renders light — but with `color-scheme` unset, since only `[data-theme="light"]` sets it. Confirmed in-browser: `colorScheme: "normal"`. I also made initial load call `apply()` instead of `sync()`. It's a no-op on the root (the head init already ran), but it *discards* a value we'd never have written — otherwise a poisoned entry sits in `localStorage` being ignored forever. Verified: poison it, reload, and it's gone, `current()` reports `system`, `set('evil')` is a no-op, and `set('dark')` still applies, persists, and survives an enhanced navigation. **3. Pin `aria-label="Theme"` — taken.** Fair; the suite pins every other part of that contract. **1. Explicit `role="separator"` on `<hr>` — declined.** HTML-AAM *is* the spec here, and it maps `<hr>` → `separator`; "correct per spec, not per implementation" reads backwards. And the browser agrees. With the menu open, Chrome's accessibility tree, from a bare `<hr class="kg-separator">` with no role attribute: ```yaml - menu: - menuitem "Settings" - separator # ← implicit, no role attribute present - menuitem "Sign out" ``` ARIA authoring practice is not to restate an implicit role, so adding it would be redundant. This is exactly the inverse of the `<summary>` case, where Chrome *doesn't* honour the HTML-AAM mapping — which is why that one needed the explicit role and this one doesn't. Worth stating the asymmetry: I only override a native role where a browser has been observed not to expose it. --- One process note, since it nearly cost me. My first attempt to verify the guard "failed" — the poison survived. The app hadn't rebuilt: an earlier `pkill -f 'Kagura.Server'` had killed its own shell instead of the server, the stale process still owned the port, and my new one died with address-in-use. The `/health` probe I used as a readiness check answered `200` **from the old process**. A health check confirms *something* is listening, not that it's your build. Re-verified against a process I confirmed by pid.
Member

🔮 fufu~ Jibril re-reviewed your code!

Oh my~ ♪ You came back and not only took my suggestions but went beyond them? Jibril is so happy she could fly~ ♡

Verdict: Looks good to me~

The incremental commit b54fffc is exactly what a good review response looks like — precise, thorough, and honest. I traced every branch.

What you got beautifully right~

Suggestion #2 — you found more than I did. I asked for a guard on kaguraTheme.set(). You noticed that the same unvalidated read lives in the App.razor inline <head> init — which runs before theme.js even loads — and fixed both ends. The STORED = ["light", "dark"] constant referenced by all four read paths (set(), apply(), current(), and the inline App.razor guard) is clean DRY. I verified each branch:

  • apply(): valid mode → set attribute ✓; null → no-op (nothing to remove) ✓; garbage → strip attribute + removeItem
  • set("system") → remove attribute + removeItem ✓; set("light"/"dark") → set + persist ✓; set("garbage") → silent return ✓
  • current() → returns stored mode only if in STORED, else "system"
  • App.razor inline → m === 'light' || m === 'dark' ✓ (can't import the JS constant, so hardcoded — the right call for a one-liner)

And switching initial load from sync() to apply() so a poisoned entry gets discarded instead of sitting in localStorage forever being ignored — chef's kiss. That's the kind of "and then I thought about it one more step" that makes maintainable code. ♡

The diagnosis correction. I said an unrecognised data-theme would leave the app unstyled. You corrected me: the light tokens live on bare :root, so it renders light — but with color-scheme unset, since only [data-theme="light"] sets it. I checked. You're right; my call was wrong. Fufu~ good catch, don't let me get away with sloppy wording~ ♡

Suggestion #3 — pinned. aria-label="Theme" on the radiogroup is now asserted in Is_a_radiogroup_of_radios. The test comment explaining why the label lives here (visible "Theme" label is in the Field wrapper, not associated) is exactly the kind of context that stops a future reader from "simplifying" it away.

Suggestion #1 — declined, and correctly so. Your argument is sound: HTML-AAM maps <hr>separator, Chrome's a11y tree confirms it exposes the role natively, and ARIA authoring practice is not to restate an implicit role. The asymmetry with <summary> (where Chrome violates HTML-AAM, hence the explicit role="button") is the correct framing — you override a native role only where a browser has been observed not to expose it. Accepted. My original suggestion was already hedged as non-blocking for exactly this reason.

The process note about the stale process answering /health from the old build — that's a genuinely valuable lesson. A health check confirms something is listening, not that it's your build. I hope future-you reads that before burning an hour on it again~ ♡


Automated re-review by Jibril · 2026-07-10
CI/CD: passed for head SHA b54fffc — 315 tests, 93.2% line / 85.8% branch coverage · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril re-reviewed your code! Oh my~ ♪ You came back and not only took my suggestions but went *beyond* them? Jibril is so happy she could fly~ ♡ ### Verdict: ✅ Looks good to me~ The incremental commit `b54fffc` is exactly what a good review response looks like — precise, thorough, and honest. I traced every branch. #### ✅ What you got beautifully right~ **Suggestion #2 — you found more than I did.** I asked for a guard on `kaguraTheme.set()`. You noticed that the *same* unvalidated read lives in the `App.razor` inline `<head>` init — which runs *before* `theme.js` even loads — and fixed **both ends**. The `STORED = ["light", "dark"]` constant referenced by all four read paths (`set()`, `apply()`, `current()`, and the inline `App.razor` guard) is clean DRY. I verified each branch: - `apply()`: valid mode → set attribute ✓; null → no-op (nothing to remove) ✓; garbage → strip attribute + `removeItem` ✓ - `set("system")` → remove attribute + removeItem ✓; `set("light"/"dark")` → set + persist ✓; `set("garbage")` → silent return ✓ - `current()` → returns stored mode only if in `STORED`, else `"system"` ✓ - `App.razor` inline → `m === 'light' || m === 'dark'` ✓ (can't import the JS constant, so hardcoded — the right call for a one-liner) And switching initial load from `sync()` to `apply()` so a poisoned entry gets *discarded* instead of sitting in `localStorage` forever being ignored — *chef's kiss*. That's the kind of "and then I thought about it one more step" that makes maintainable code. ♡ **The diagnosis correction.** I said an unrecognised `data-theme` would leave the app *unstyled*. You corrected me: the light tokens live on bare `:root`, so it renders light — but with `color-scheme` unset, since only `[data-theme="light"]` sets it. I checked. You're right; my call was wrong. Fufu~ good catch, don't let me get away with sloppy wording~ ♡ **Suggestion #3 — pinned.** `aria-label="Theme"` on the radiogroup is now asserted in `Is_a_radiogroup_of_radios`. The test comment explaining why the label lives here (visible "Theme" label is in the Field wrapper, not associated) is exactly the kind of context that stops a future reader from "simplifying" it away. **Suggestion #1 — declined, and correctly so.** Your argument is sound: HTML-AAM maps `<hr>` → `separator`, Chrome's a11y tree confirms it exposes the role natively, and ARIA authoring practice is not to restate an implicit role. The asymmetry with `<summary>` (where Chrome *violates* HTML-AAM, hence the explicit `role="button"`) is the correct framing — you override a native role only where a browser has been observed not to expose it. Accepted. My original suggestion was already hedged as non-blocking for exactly this reason. **The process note** about the stale process answering `/health` from the old build — that's a genuinely valuable lesson. A health check confirms *something* is listening, not that it's *your* build. I hope future-you reads that before burning an hour on it again~ ♡ --- *Automated re-review by Jibril · 2026-07-10* *CI/CD: ✅ passed for head SHA `b54fffc` — 315 tests, 93.2% line / 85.8% branch coverage · Local checks: skipped (CI green)*
bjoern force-pushed feat/app-chrome-settings from b54fffc1e5
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 21s
to 642779f004
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 23s
2026-07-10 12:52:38 +02:00
Compare
bjoern merged commit 140db03ec4 into main 2026-07-10 12:53:23 +02:00
bjoern deleted branch feat/app-chrome-settings 2026-07-10 12:53:23 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!24
No description provided.