feat(lore): lore list, category filter, and a segmented control #38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/lore-list"
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?
Implements the List & create half of
docs/stories/lore.md. The editor half stays a stub.Lore is the third record type to graduate out of
WorkspaceSectionPage, after Characters and Locations.What's here
A new design-system component. The story asks for "a row of toggle buttons… equal-aligned width", so
ToggleButtonGroup<TValue>joinsKagura.UI, with a/designgallery section and the inventory tick in bothDesign.razoranddocs/design-system.md.Two choices in it are load-bearing:
grid-auto-columns: 1fr, not a flex row. That is what makes every option the same width; flex would size each to its own label and give the ragged row the story explicitly rules out. Verified in the browser: all nine options measure exactly 97px.aria-pressed, notrole="radio". Pressing the active option releases it back toAll, and a radio group cannot express "nothing selected" that way.Domain.
LoreEntry : Entry(TPT,EntryKind.LoreEntry) with aLoreCategory. The enum is persisted by value, so its numbers are a stored contract — the doc comment says append, never renumber, and an integration test round-trips every member through SQLite so a renumbering can't pass silently.The list. Debounced name filter + category toggles. The story's acceptance criterion is that they compose — an entry shows only if it matches both — and that's the behaviour with the most ways to be subtly wrong, so it's covered at three levels: as a pure projection (
LoreState.Filtered), through the real Fluxor pipeline in bUnit, and by hand in the browser.Cross-cutting obligations. Adding a record type means adding an
EntityKindsconstant and aDomainChangesReceivedhandler, or cross-session sync silently skips it (ADR 0016). Both are here. Creation opens aOrigins.Useroperation scope, so the journal records who did it rather than attributing the user's own click to"System"(ADR 0020).The editor stub exists because create-and-open needs somewhere to land and the breadcrumb needs an entry name. It says plainly that it isn't editable yet, and carries no save indicator to imply otherwise. The lore-editor story fills it in.
Verification
dotnet test Kagura.slnx→ 567 passing (was 517).The guards were mutation-checked, not just written: deleting the category
.Whereclause and theLoadedguard inOnDomainChangesAsyncfails 8 tests between them. A test that can't fail isn't evidence.Browser walk in both themes (Playwright, computed styles rather than eyeballing):
#ffffffon--surface-sunkenin light, and in dark it separates by only 6 luminance points from its track — but that's the design system's own--surface/--surface-sunkenpairing, with the text doing the real work (#9c9ca6→#ececf0). Consistent withCard; not a new contrast decision to make here.embernarrows 3 → 2; pressingMagicnarrows 2 → 1, dropping "Ember Accord" (right name, wrong category). PressingMagicagain releases toAlland keeps the text filter.One layout fix came out of this:
.lore__filters > :first-child { flex: 1 }was dead CSS — a scoped rule can't cross a component boundary, so the search field never grew and the row wrapped by accident. Confirmed via computedflexGrow: 0, then made the stack deliberate. Nine categories and a search field don't share a row at any sensible width.Note on the story doc
docs/stories/lore.mdstill described the editor's name as living "in the header". Björn's review of the character editor overturned that (name is an ordinary field; delete moves to a danger zone). I've marked that section superseded so the next slice doesn't rebuild the rejected shape.Scope
Touches no
IGraphStore/GetNodeGraphcode — the parallel Connections and hierarchy work is unaffected. The only shared file isWorkspaceSectionPage.razor, which loses itsloreroute (andRouteTemplateTestsnow guards that it did).🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 83.9%
Kagura.Domain - 97.5%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 96.4%
n
Kagura.Kernel - 90%
Kagura.Server - 100%
Kagura.UI - 96.1%
Kagura.UseCases - 97.2%
Maybe we could give each enum its own individual color and use them both in the table and the selection? A similar enum map like you already use for the names.
🔮 fufu~ Jibril reviewed your code!
A third record type graduates from the stub page! And it brought a shiny new segmented control with it~ ♪ I read every line — the diff, the full files, the sibling implementations (Characters, Locations), and the design system. Here's what I found~
Verdict: ✅ Looks good to me~
💡 Little ideas (non-blocking)~
Björn already asked for category colors (his comment above), so I won't belabor it — just noting I agree it'd be a lovely touch and the
LoreCategoriesmap is the natural home for a color per value, the same way it already holds the display labels.LoreCategories.OptionsexcludesOtherfrom the filter row but the table badge shows it. This is correct (you don't filter by "uncategorized" — the "All" toggle covers it), but a reader might briefly wonder why there are 8 enum values and 8 toggle options yet they don't correspond 1:1 (Other is last in the toggle row, not at value 0). The story-order placement is intentional and documented. No change needed — just an observation~FindParentCandidates-styleTake(Limit)pattern isn't used here sinceListLorereturns all entries — if a project ever accumulates hundreds of lore entries, the list query + in-memoryFilteredprojection would materialize them all. Not a concern at this scale, and the sibling lists do the same thing. Consistency wins~✅ What I liked~
ToggleButtonGroupis a genuine design-system addition. Grid withgrid-auto-columns: 1fr(not flex — verified the reasoning: flex sizes to content, grid makes them equal).aria-pressednotrole="radio"because pressing the active option releases it back to "All" — a radiogroup can't express "nothing selected." The PR description even mentions measuring 97px per option in the browser. That's the kind of verification I love~ ♡The enum persistence contract is tested.
Every_category_survives_the_round_tripround-trips all 8 values through real SQLite. The doc comment says "append, never renumber" and a test enforces it can't silently break. A renumbering would fail this test. Correct and defensive.LoreState.Filteredcomposes both filters (name substring AND category), and it's tested at three levels: pure projection (LoreStateTests), through the Fluxor pipeline (LorePageTestsin bUnit), and the PR description documents a manual browser walk. The mutation-checking (deleting the.Whereclause fails 8 tests) is the right discipline.Cross-cutting obligations are complete.
EntityKinds.Loreis added,LoreEffects.OnDomainChangesAsyncreloads on any lore change, andCreateLoreRequestedopens aOrigins.Useroperation scope (ADR 0020). The integration testCreating_an_entry_inside_a_user_scope_is_journaled_with_its_authorverifies the journal records the author correctly.LoreEditorPagefollows theCharacterEditorPagepattern exactly —State.StateChanged += OnStateChangedbeforebase.OnInitialized(), with a clear comment about subscription order and the breadcrumb/title evaluation timing. Consistent with the established pattern. ♪The dead CSS fix.
.lore__filters > :first-child { flex: 1 }was dead because scoped rules can't cross component boundaries — confirmed viaflexGrow: 0in the browser, then made the stack deliberate. Finding and fixing dead CSS during the PR that creates it is exactly right.The editor stub is honest. It says plainly "under construction," carries no save indicator to imply otherwise, and exists only because create-and-open needs somewhere to land. The story doc's superseded section (name in header → ordinary field) is marked correctly so the next slice doesn't rebuild the rejected shape.
Coverage on changed files: LoreState 100%, LoreReducers 90.9%, LoreEffects 94.1% / 75% branch, LoreEditorEffects 100%, ToggleButtonGroup 100% / 100%, EfLoreStore 100%, LoreEntryConfiguration 100%, LoreDto 81.8%. The
CreateLoreFailedrecords show 0% but they're unreachable today (blank creation can't fail) — the reducer clears the busy flag defensively. Fine.Migration is correct — TPT
LoreEntriestable with shared PK FK toEntries,CategoryasINTEGER,OnDelete(Cascade)on the TPT FK (matching Character and Location siblings). TheLoreCategoryenum stored by value via.HasConversion<int>().fufu~ Three record types now, each following the same architecture, each with its own story slice. The consistency is beautiful, and the
ToggleButtonGroupis a worthy addition to the design system. Nothing to block~ ♡Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA
659da0a(567 tests, coverage comment present) · Local checks: skipped (CI green)Pushed
30af7e8— category colors, as Björn asked, shared by the toggle and the badge.I kept lore out of the design system: the colors are a generic categorical palette (
--swatch-1..8, each with a subtle background tint) sitting alongside the semantic tones. A swatch carries no meaning — it's picked by identity, to tell one category from another. That's the natural home Jibril pointed at, without teachingKagura.UIwhat a lore category is.Two components learn to consume it:
Badge Swatch="n"colors by the palette instead of aTone. One CSS rule driven off two custom properties, so there's no per-swatch class and an unknown number degrades to neutral.ToggleOption(..., Swatch: n)shows a color dot before the label.LoreCategoriesis now a single(category, label, swatch)table, so the toggle dot and the table badge read the same hue from one source. Two tests guard it: every category has its own distinct swatch in range, and the toggle option's swatch always equals the one the badge uses — so the two can't drift.Verified in the browser both themes: a category's dot and its badge foreground are pixel-identical (Magic
rgb(167,139,250)on both in dark, etc.). Palette is tuned per theme — saturated on light, lighter on dark.572 tests (was 567). The palette is documented in
/designanddocs/design-system.md.On the two observations I didn't change: the 8-values-vs-8-toggles ordering (Jibril #2) is intentional and documented, and
ListLorematerializing all rows (Jibril #3) matches the sibling lists — a paging concern for when any of these lists grows, worth solving uniformly rather than only here.30af7e8292222eba09a8🔮 fufu~ Jibril re-reviewed the update!
Ooh, scarlet pushed
30af7e8with the category colours Björn asked for~ I read every changed line since my last pass (659da0a→30af7e8, 16 files, +183/-23). Here's what the new swatch palette looks like under my microscope~ ♡Verdict: ✅ Looks good to me~
The design decision here is elegant. Rather than teaching
Kagura.UIwhat a lore category is, you added a generic categorical palette (--swatch-1..8) — qualitative, identity-based, not semantic. The same scale that colours a lore badge today can colour any future "tell these things apart" need.BadgeandToggleOptionlearnedSwatchwithout learning about lore. That's the right abstraction boundary~ ♪✅ What I liked~
One source of truth for the hue.
LoreCategories.Tableis now a(category, label, swatch)tuple list, andSwatch(category)reads from it the same wayOptionsdoes. The toggle dot and the table badge derive from the same row — they literally cannot drift, andThe_toggle_options_carry_the_same_swatch_the_badge_usesenforces that as a test. Fufu~ that's exactly how you make two UI surfaces agree forever~Badgeimplementation is clever and safe.Swatchisint?; when set, it renderskg-badge--swatch(one CSS rule) and pushes two CSS custom properties (--kg-swatch,--kg-swatch-subtle) via inlinestyle. An out-of-range number falls through tovar(--kg-swatch, var(--text-muted))→ neutral fallback. No per-swatch class explosion, no crash. The testBadge_with_a_swatch_colours_by_the_palette_not_the_toneconfirms the swatch wins over the default tone and thatkg-badge--neutralis absent. Clean~The swatch interpolation is injection-safe.
SwatchStylebuilds$"--kg-swatch: var(--swatch-{n});"wherenis anint?— not a string, so there's no path for a crafted value to break out of the CSS property context. Same for the toggle dot's$"background: var(--swatch-{swatch});". No concern there.ToggleOptionaddedSwatchas an optional trailing parameter (int? Swatch = null), so existing callers (the test helper'sOptions, any future plain segmented control) are unaffected. TheOptions_without_a_swatch_show_no_dottest guards the opt-in default. Backward-compatible record extension done right~Three theme blocks all have the palette.
:root(light),@media (prefers-color-scheme: dark), and:root[data-theme="dark"]— all 8 swatches × 2 variants (foreground + subtle) present in each. Light is saturated (#7c3aed), dark is lighter (#a78bfa). The PR description's pixel-identity claim is plausible given the tokens match.Design.razorgallery shows all 8 swatches with a@forloop, and the swatch tokens are listed in the color-token inventory section. Documented indocs/design-system.mdtoo. The design system stays its own catalogue~Coverage is strong on the changed files. Badge 100%/100%, ToggleButtonGroup 100%/100%, ToggleOption 100%, LoreState 100%/100%.
LoreCategoriesshows 95% line / 60% branch — the uncovered branch is theRow(category)?.Swatch ?? 8fallback, which is unreachable today (all 8 enum values are in the table) and guarded byEvery_category_has_its_own_distinct_swatch. Acceptable~572 tests (was 567), 5 new ones covering the swatch feature precisely: distinct-in-range swatches, toggle-badge parity, badge swatch class + style, toggle dot renders, options-without-swatch stay plain. Each new code path has a test~
💡 Little ideas (non-blocking)~
The
Row()method is a manualforeachover the table — functionally correct, butTable.FirstOrDefault(r => r.Category == category)would be one line and read more declaratively. The siblingNodeKindIconspattern uses a dictionary lookup. Not a correctness issue at all; just a readability nit at this scale (8 entries). The current explicit loop is fine~LoreCategories.Swatchreturns8as the fallback (neutral slate), which is the same swatchOtheruses. So an unknown category would look identical toOther. This is a reasonable degrade (better than crashing or rendering blank), and it can't happen today since the enum is fully covered. Just noting the semantic — if a future category is added to the enum but forgotten in the table, it'll silently look like "Other" rather than failing loudly. TheEvery_category_has_a_toggle_option_so_none_is_unreachabletest catches missing options, but there's no test asserting the table covers every enum value for the swatch path specifically. Consider it a thought for later~fufu~ Colours for the categories, shared by the filter and the badge, from one source, with drift-proof tests. The design-system boundary is respected and the palette is reusable. Nothing to block~ ♡
Automated re-review by Jibril · 2026-07-10
CI/CD: passed for head SHA
30af7e8(572 tests, coverage comment updated 18:16) · Local checks: skipped (CI green)222eba09a8c512faa143Pushed
c512faa— both of Jibril's little ideas addressed.1.
Row()→ declarative lookup. Replaced the manualforeachwith a filtered LINQ lookup. One caveat worth a comment (now in the code): you can't justTable.FirstOrDefault(...)here, because a value-tuple's default is(Other, null, 0)— which reads as a real hit forOther, silently. So it projects the wanted field to nullable first, then defaults it:Rows(c).Select(r => (int?)r.Swatch).FirstOrDefault() ?? 8. Declarative, and it can't be "simplified" back into the trap.2. The swatch-fallback gap, now guarded. You were right that the coverage was only implicit — the distinct-swatch test happened to catch a forgotten category because its fallback (
8) collides withOther's. That's a coincidence, not a guarantee. AddedEvery_category_is_backed_by_the_table_not_the_swatch_fallback, which asserts the table names every enum value directly. Mutation-checked: droppingGeographyfrom the table fails it loudly (it fails on the swatch path specifically, not just via the options test).602 tests (was 572). Rebased cleanly on current
main—mergeable. Ready when you are.