Design-system foundation: tokens, theme, icons, base components (ADR 0023) #8
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/design-system"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The
Kagura.UIvisual language — the token layer every page (and the access-gate page from #7, which shipped intentionally unstyled) consumes. Scoped to theKagura.UIRCL so it doesn't collide with the gate work onKagura.Server; the one small overlap is an additiveApp.razor<head>reconcile at rebase.What's in it
Tokens + theme (
wwwroot/css/kagura-ui.css) — reset, design tokens as CSS custom properties (semantic color roles, 4px spacing scale, type scale, radii, elevation, z-layers, motion), base element styles, and a few flex utilities. Light + dark, where an explicitdata-themewins overprefers-color-scheme(ADR 0016). Components consumevar(--…)only — no hardcoded colors or magic numbers.Font & theme control — self-hosted Inter (variable woff2) +
theme.js(system/light/dark, persisted to localStorage). The host applies the saved theme before first paint via a tiny inline script, so there's no flash.Icons —
<Icon>renders self-hosted Material Symbols inline as SVG viacurrentColor(inherits text color + theme; not a rigid icon font), backed byIconCatalog(25 seed icons; add an entry to add one).Base components — each with scoped, token-only CSS: Button (variants/sizes/icon/loading/disabled), Card, TextField, Badge, StatusDot, EmptyState. The rest of the ADR 0023 inventory arrives as pages need them.
A live gallery at
/design— every token, all 25 icons, and each component in its states, with a working light/dark/system switcher. Living documentation and the visual acceptance surface.A placement note
The gallery lives in
Kagura.Server, notKagura.BlazorAdapter. SSR endpoint routing only discovers pages in the app assembly unlessProgram.cscallsAddAdditionalAssemblies— and #7 ownsProgram.cs. Putting this host-level reference page inKagura.Serverroutes it for free with zeroProgram.cschurn; feature pages inBlazorAdapterget that route registration wired with the first real one (the project list).Tests — 101 (was 97; +4)
New
Kagura.UI.TestsoverIconCatalog(known → path, unknown → null, names sorted/unique, every name resolves). It's also the home for bUnit component tests later.Verification
dotnet build(Debug + Release) — 0 warnings / 0 errors.dotnet test— 101/101 pass./designserves 200; the RCL stylesheet, the Inter woff2, andtheme.jsall serve 200 (fingerprinted_content/Kagura.UI/…); the page renders every component consuming tokens.Rebase note
Per the coordination on the gate: this and #7 touch disjoint files except
App.razor(both add<head>links) — a trivial additive reconcile. Once both land, the gate page becomes the token layer's first real consumer.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 0%
Kagura.Domain - 96.4%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 95.1%
n
Kagura.Kernel - 90%
Kagura.Server - 94.8%
Kagura.UI - 98.3%
Kagura.UseCases - 95.2%
A placement note
The gallery lives in Kagura.Server, not Kagura.BlazorAdapter. SSR endpoint routing only discovers pages in the app assembly unless Program.cs calls AddAdditionalAssemblies — and #7 owns Program.cs. Putting this host-level reference page in Kagura.Server routes it for free with zero Program.cs churn; feature pages in BlazorAdapter get that route registration wired with the first real one (the project list).
Please redo that once #7 is merged
🔮 fufu~ Jibril reviewed your code!
Oh? OH~ ♡ A design system foundation! CSS custom properties with semantic roles, a 4px spacing scale,
data-themewinning overprefers-color-scheme, inline SVG icons viacurrentColor… fufu~ You even pre-applied the saved theme before first paint to avoid a flash. This is gorgeous foundation work. The token discipline alone — every component consumingvar(--…)and nothing else — made Jibril's wings flutter~ ♡But Jibril reads everything, and the CI coverage comment told her a story the PR description didn't.
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
[Coverage gap — 7 of 8 shipped classes are 0% covered] — The component layer ships untested.
The CI coverage bot (comment above, for head SHA
4508938) reportsKagura.UIat 30% line / 0% branch. Breaking it down:IconCatalogBadgeButtonCardEmptyStateIconStatusDotTextFieldCssClassExtensionsThat's 1 of 8 shipped types tested. The
IconCatalogTestsare excellent (known → path, unknown → null, sorted/unique, every name resolves) — but they cover the data layer. The component layer — the thing this PR exists to ship — has zero coverage.The PR description says
Kagura.UI.Tests"is also the home for bUnit component tests later." Butlateris not a test plan.bunitisn't even inDirectory.Packages.propsyet — there's no package reference, no using, nothing. The infrastructure to write these tests doesn't exist in the repo right now. So "later" means "someone has to set up bUnit from scratch first," and that someone might not realize the branches exist.Why I'm blocking: these components have real branching logic that can and will break silently:
Button:LoadingvsIconvsChildContent(three-way conditional),disabledwiring,aria-busytoggle,FullWidthmodifier classTextField:ErrorvsHintbranch,Requiredasterisk toggle,aria-invalidbinding, the@oninput→ValueChangedround-tripStatusDot:Label is null→ decorative (aria-hidden, norole) vs labelled (role="img") — an a11y branch with no testIcon: sameLabel is nulla11y branch, plus_pathnull when name is unknown (renders empty<svg>)EmptyState: three independentis not nullconditionalsCssClassExtensions.Css():ToLowerInvariant()— trivial, but it's the thing every modifier class depends onEvery one of these is a branch a refactor could silently flip. fufu~ You built a foundation — the thing every future page depends on. The cost of a silent break here compounds across the whole app. A foundation you can't verify isn't a foundation, it's a hope~ ♡
Fix: Add
bunittoDirectory.Packages.props, reference it inKagura.UI.Tests, and add component tests that render each component in its key states and assert the rendered markup/attributes. You don't need exhaustive state matrices — but the conditionals I listed above (loading/icon/label/error branches, the a11y attribute toggles, the modifier class emission) need at least one assertion each. That's what closes 0% → meaningful. The gallery at/designis a great visual acceptance surface, but it doesn't count as test coverage — CI can't read a screenshot.[
TextField.razor:1] —@oninputwithout@bindis a footgun for two-way binding.The component takes
Value+ValueChangedbut binds the DOM viavalue="@Value"and@oninput. This is a hand-rolled one-way-down + event-up pattern. It works for the gallery demo, but the moment a consumer writes<TextField @bind-Value="model.Name" />, Blazor's[Parameter] Value+[Parameter] ValueChangedpair is what makes@bindwork — and that part is wired correctly. However,@oninputfires on every keystroke and invokesValueChanged.InvokeAsyncsynchronously, which means every keystroke triggers a full re-render of the parent's handler. For a text field that's usually fine, but if the component is meant to be a real input primitive (and the inventory says it is), consider documenting that@bind-Valueis the intended consumption pattern, or addValueExpressionso@bind-Valueworks with validation ([EditableObject]/EditContext). Right now@bind-Valuecompiles but the field won't participate inEditFormvalidation correctly withoutValueExpression.Why this is blocking, not a suggestion: the gate page in #7 uses
EditForm+InputText(the framework built-in) precisely because it needs validation. IfTextFieldis meant to replaceInputTextfor styled forms, it must support the same validation contract. If it's not meant to (it's display-only), that should be documented. Right now it's ambiguous and a consumer will reach for it in a form and get silently-broken validation.Fix: Either add
[Parameter] public Expression<Func<string?>>? ValueExpression { get; set; }(and pass it through) so@bind-Value+EditContextvalidation works, or add a/// <remarks>clarifying this is a lightweight input not intended forEditFormvalidation flows.💡 Little ideas (non-blocking)~
[
Design.razor:11-14] —onclick="kaguraTheme.set('…')"is inline JS, not a Blazor handler. It works becausetheme.jsis a global IIFE, but it's the only inlineonclickin a Blazor codebase. Consider<button @onclick="() => SetTheme("system")">with a tiny@codemethod for consistency — or leave it, since the gallery is deliberately a static SSR page. Just noting the inconsistency.[
kagura-ui.cssdark theme duplication] — the@media (prefers-color-scheme: dark)block and:root[data-theme="dark"]block are byte-identical (every token repeated). DRY-wise, CSS doesn't make this easy to share without preprocessor@extendor a shared selector group. One option::root[data-theme="dark"], :root:not([data-theme])scoped inside the media query via@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }— then the explicit block only needs thecolor-schemedeclaration. Up to you; the current approach is explicit and readable, just verbose.[
IconCatalog.cs—descriptionandfoldericons] — thedescriptionandfolderpaths look unusually long. Just a sanity-check note: verify they render correctly in the gallery (thedescriptionpath in particular has a lot of subpaths). If they look right at/design, ignore this~✅ What I liked~
var(--…)and onlyvar(--…)across every component CSS file. No hardcoded hex, no magic rem values. This is exactly what ADR 0023 demands. The foundation is clean~data-themewins overprefers-color-scheme— the cascade order is correct::root[data-theme="dark"]overrides the media query, and:root[data-theme="light"]exists to pin light when the OS is dark. Textbook~<script>inApp.razorapplies the saved theme before first paint. Tiny, correct, and thetheme.jsIIFE is clean. ♡currentColor— not a rigid icon font. Icons inherit text color and theme automatically. TheIconCatalogas a dictionary keyed by Material Symbols name (add an entry → add an icon) is a nice extensibility story.StatusDotpulse animation —color-mix(in srgb, currentColor 45%, transparent)for the shadow. Modern, theme-aware, no extra color tokens needed. Elegant~reduced-motionmedia query —animation-duration: 0.01ms !importantacross the board. Accessibility-aware from day one. ♡Kagura.Servernow, move toBlazorAdapterwhenProgram.csis free" reasoning is sound. Björn's already flagged the follow-up.Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA
4508938(91.5% line / 67.8% branch overall, but Kagura.UI at 30%/0% — 7 of 8 shipped component classes untested) · Local checks: skipped (CI green, but green CI ≠ correct — coverage confirms the gap)450893830282ae46cf8dRebased onto
main(now that #7 is in) and addressed both blockers + Björn's placement ask. Fixed in82ae46c.🧩 Björn — gallery placement
Moved the
/designgallery fromKagura.Serverback toKagura.BlazorAdapter, andMapRazorComponents<App>()now calls.AddAdditionalAssemblies(typeof(Kagura.BlazorAdapter.AppInfo).Assembly)so the primary-adapter page is discovered by SSR endpoint routing (ADR 0003). Verified behind the real gate: log in →GET /design= 200 with every component; a nonexistent route = 404 (so the route is genuinely discovered, not just gate-redirected).⛔ 1 — the component layer is now tested (bUnit)
Brought bUnit in (
Directory.Packages.props+Kagura.UI.Tests) and covered every branch you listed — +35 tests:disabled,aria-busy), full-width, disabled, click forwarding.aria-invalid, the@oninput → ValueChangedround-trip, and EditForm validation (below).<path>, unknown → empty<svg>, and theLabel-null decorative (aria-hidden/role=presentation) vs labelled (role=img) a11y branch.EmptyStateconditionals.InternalsVisibleTo).Kagura.UIgoes from 30%/0% to real coverage; the gallery stays as the visual surface, not the test surface.⛔ 2 — TextField now supports the EditForm validation contract
You were right that it was ambiguous.
TextFieldis now a proper input primitive: addedValueExpression+ an optional cascadedEditContext, so@bind-Valueparticipates inEditFormvalidation — it surfaces the field's own validation message and the invalid styling, and re-renders onOnValidationStateChanged— while staying usable standalone (noEditContextrequired, unlike deriving fromInputBase). An explicitErrorstill overrides. Covered by aDataAnnotationsEditForm test (Reflects_EditForm_validation_messages_for_its_bound_field).💡 Non-blockers
onclickon the gallery theme switch — kept; it's still a deliberately static SSR page (now in BlazorAdapter), so a globaltheme.jshandler is the right call over a circuit.light-dark()rewrite is a tempting follow-up but out of scope here.description/folderlong paths — confirmed they render correctly at/design(they're the official Material Symbols paths).155 tests total (35 UI + 76 UseCases + 44 integration). Build clean (Debug + Release), CI green on
82ae46c. Ready for another look — and once merged, the gate page becomes the token layer's first real consumer.🔮 fufu~ Jibril reviewed your code!
Oh? Oh! You came back, and you brought bUnit, and an EditForm-capable TextField, and the gallery moved to its proper home… fufu~ ♡ Jibril is so pleased. Let me show you what I checked~
Verdict: ✅ Looks good to me~
Both blockers from the last round are properly resolved, and I verified each one against the code and the CI coverage, and locally — not just the PR description.
⛔ #1 (component layer untested) — resolved and verified
bUnit is in (
Directory.Packages.props+Kagura.UI.Tests.csproj),InternalsVisibleTois set, and 35 component tests cover every branch I listed. I read each test file and matched assertions to the conditionals:disabled+aria-busythree-way, icon-when-not-loading, full-width block class, disabled attribute, click forwarding viaExtra. ✅aria-invalid,@oninput→ValueChangedround-trip, and the new EditForm validation test. ✅<path d>, unknown→empty<svg>, decorative (aria-hidden/role=presentation) vs labelled (role=img/aria-label), explicit size dimensions. ✅role/aria-label) vs labelled (role=img), tone class, pulse modifier. ✅EmptyStateconditionals (icon/body/actions presence). ✅InternalsVisibleTo. ✅CI coverage confirms it:
Kagura.UIwent from 30%/0% → 98.3% line / 95.9% branch. Every shipped component class is now at 100% exceptTextField(95%/91.6% — theDisposenull-guard branch, which is expected). I also ran the suite locally: 35/35 pass. The gallery stays the visual surface; the tests are the verification surface. Exactly right~ ♡⛔ #2 (TextField EditForm validation contract) — resolved and verified
TextFieldis now a proper input primitive. The implementation is correct:[Parameter] ValueExpression+[CascadingParameter] EditContext→BoundToEditContextgate.OnParametersSetsubscribes/unsubscribes viaReferenceEqualsguard — no double-subscribe, no leak when theEditContextswaps._field = FieldIdentifier.Create(ValueExpression!)recomputed each parameter set (handlesValueExpressionchanging, which is rare but possible).OnInputcallsEditContext.NotifyFieldChanged(_field)afterValueChanged→ validation re-runs on each keystroke.OnValidationStateChanged→StateHasChanged→ the field re-renders to surface/clear messages.Disposeunsubscribes — no dangling event handler.@implements IDisposableis present. ✅Errorstill wins over validation messages (correct precedence:Error ?? ValidationMessage ?? Hint).The test
Reflects_EditForm_validation_messages_for_its_bound_fieldexercises the real path:EditContext+EnableDataAnnotationsValidation+Validate()→ invalid class + message appears. And it stays usable standalone (noEditContextrequired) — theBoundToEditContextgate means a bare<TextField>without a form just works. This is the right design; it doesn't forceInputBaseinheritance and its constraints. ♡🧩 Björn's placement ask — resolved
Gallery moved from
Kagura.Server→Kagura.BlazorAdapter, andProgram.csnow calls.AddAdditionalAssemblies(typeof(Kagura.BlazorAdapter.AppInfo).Assembly)onMapRazorComponents<App>().AppInfo.razorexists in the adapter assembly, so thetypeof(...).Assemblyreference is real. The SSR route discovery constraint (ADR 0003) is satisfied properly — not a hack. The author verifiedGET /design= 200 behind the real gate and a bogus route = 404, confirming genuine discovery.✅ What I liked (this round)
ReferenceEqualssubscription guard — theOnParametersSetlogic correctly handlesEditContextidentity changes without leaking subscriptions. This is the detail that separates a working demo from a production input. fufu~ you knew~ ♡BoundToEditContextgate — keeping the component usable standalone while also supporting full EditForm validation is the harder, correct choice over forcingInputBase. Elegant.Errorprecedence over validation — explicit error wins. Right call; an imperativeError="…"shouldn't be silently clobbered by form state.var(--…)reference across all component CSS files against the token definitions inkagura-ui.css. Zero orphaned tokens. Every reference resolves. The foundation is airtight.Ship it~ ♡ Once this lands, the gate page gets its first real token consumer and the design system starts paying rent.
Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA
82ae46c(94.4% line / 85.9% branch overall; Kagura.UI 98.3%/95.9%) · Local checks: 35/35 UI tests pass