List components: Table, DebouncedSearchField, PreviewImage, RelativeTime #12

Merged
bjoern merged 2 commits from feat/list-components into main 2026-07-09 19:41:26 +02:00
Member

The reusable Kagura.UI pieces the project-list (and later characters/locations/lore/chapters/queue) compose into the RecordListPage pattern (ADR 0023; the docs/design-system.md inventory from #11). Building them as proper components before the page keeps the pattern from being hand-rolled and duplicated across every list — exactly what the design system exists to prevent.

Following the inventory's build-order signal (Uses): Table (6), DebouncedSearchField (5), PreviewImage (5), RelativeTime (2) — all directly needed by the project list.

Components

  • Table<TRow> — generic columns via TableColumn children (a cascading type parameter infers TRow from Items, so consumers just list <TableColumn>s). Keyboard-accessible clickable rows (tabindex/role="button"/Enter/Space), token-styled.
  • DebouncedSearchField — search input with a leading icon + clear button; ValueChanged debounced (~250 ms, configurable via DebounceMs), immediate on clear (clearing cancels the pending debounce).
  • PreviewImage / Avatar — image with a generic placeholder-icon fallback when unset, aspect-boxed (Ratio, optional Width).
  • RelativeTime — timestamp → "3 hours ago" with an absolute title and a machine-readable datetime; an injectable Now keeps tests deterministic.

Follows the inventory's rules

  • Every component ships with its /design gallery demo — added a "Search, table & data" section showing all four together (a project-list-shaped table).
  • The inventory ticks flip to built (8 → 12), in both the live gallery and docs/design-system.md.

Tests — +17 bUnit (52 UI; 172 total)

  • Table: headers + a row per item, RowClick fires with the clicked row, clickable affordances present only with a handler.
  • DebouncedSearchField: reports after the debounce; clear appears only with text and reports empty immediately.
  • PreviewImage: image vs placeholder branch, ratio/width.
  • RelativeTime: every bucket (just-now → weeks → absolute), future-safe, carries datetime/title.

Verification

  • dotnet build (Debug + Release) — 0 warnings / 0 errors.
  • dotnet test — 172/172 pass.
  • Rendered behind the gate at /design: the generic column-registration works in real SSR (not just bUnit).

Not in this PR (follow-ups)

  • Modal + the shared overlay-root (focus trap / Esc / scroll lock) — the inventory calls for building that plumbing once, deliberately.
  • Fluxor + the project-list page that composes these into the actual feature (list / debounced filter / open / create).

🤖 Generated with Claude Code

The reusable `Kagura.UI` pieces the **project-list** (and later characters/locations/lore/chapters/queue) compose into the **RecordListPage** pattern (ADR 0023; the `docs/design-system.md` inventory from #11). Building them as proper components *before* the page keeps the pattern from being hand-rolled and duplicated across every list — exactly what the design system exists to prevent. Following the inventory's build-order signal (Uses): **Table (6), DebouncedSearchField (5), PreviewImage (5), RelativeTime (2)** — all directly needed by the project list. ## Components - **`Table<TRow>`** — generic columns via `TableColumn` children (a cascading type parameter infers `TRow` from `Items`, so consumers just list `<TableColumn>`s). Keyboard-accessible clickable rows (`tabindex`/`role="button"`/Enter/Space), token-styled. - **`DebouncedSearchField`** — search input with a leading icon + clear button; `ValueChanged` debounced (~250 ms, configurable via `DebounceMs`), **immediate on clear** (clearing cancels the pending debounce). - **`PreviewImage` / Avatar** — image with a generic placeholder-icon fallback when unset, aspect-boxed (`Ratio`, optional `Width`). - **`RelativeTime`** — timestamp → "3 hours ago" with an absolute `title` and a machine-readable `datetime`; an injectable `Now` keeps tests deterministic. ## Follows the inventory's rules - **Every component ships with its `/design` gallery demo** — added a "Search, table & data" section showing all four together (a project-list-shaped table). - The inventory ticks flip to **built (8 → 12)**, in both the live gallery and `docs/design-system.md`. ## Tests — +17 bUnit (52 UI; 172 total) - Table: headers + a row per item, `RowClick` fires with the clicked row, clickable affordances present only with a handler. - DebouncedSearchField: reports after the debounce; clear appears only with text and reports empty immediately. - PreviewImage: image vs placeholder branch, ratio/width. - RelativeTime: every bucket (just-now → weeks → absolute), future-safe, carries `datetime`/`title`. ## Verification - `dotnet build` (Debug + Release) — 0 warnings / 0 errors. - `dotnet test` — 172/172 pass. - Rendered behind the gate at `/design`: the generic column-registration works in real SSR (not just bUnit). ## Not in this PR (follow-ups) - **`Modal` + the shared overlay-root** (focus trap / Esc / scroll lock) — the inventory calls for building that plumbing once, deliberately. - **Fluxor + the project-list page** that composes these into the actual feature (list / debounced filter / open / create). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(ui): list components — Table, DebouncedSearchField, PreviewImage, RelativeTime
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 20s
500f139068
The reusable pieces the project-list (and characters/locations/lore/…) will compose
into the RecordListPage pattern (ADR 0023; docs/design-system.md inventory). Built as
proper Kagura.UI components before the page, so the pattern isn't hand-rolled and
duplicated across lists.

- Table<TRow>: generic columns via TableColumn children (cascading type parameter),
  keyboard-accessible clickable rows (tabindex/role/Enter/Space), token-styled.
- DebouncedSearchField: search input with leading icon + clear button; ValueChanged
  debounced (~250ms, configurable), immediate on clear.
- PreviewImage / Avatar: image with a generic placeholder-icon fallback, aspect-boxed.
- RelativeTime: timestamp → "3 hours ago" with an absolute title + machine-readable
  datetime; injectable Now for deterministic tests.

Each gets a /design gallery section (the inventory rule: components ship with their
gallery demo) and the inventory ticks flip to built (8 → 12). +17 bUnit tests (52 UI;
172 total). Build clean (Debug + Release); verified rendering behind the gate at /design.

Not here (follow-ups): Modal + overlay-root, then Fluxor + the project-list page that
composes these.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 17:23:11
Coverage date: 07/09/2026 - 17:23:06 - 07/09/2026 - 17:23:09
Parser: MultiReport (3x Cobertura)
Assemblies: 7
Classes: 70
Files: 68
Line coverage: 91.3% (1925 of 2107)
Covered lines: 1925
Uncovered lines: 182
Coverable lines: 2107
Total lines: 4007
Branch coverage: 84.9% (311 of 366)
Covered branches: 311
Total branches: 366
Method coverage: Feature is only available for sponsors

Coverage

Kagura.BlazorAdapter - 0%
Name Line Branch
Kagura.BlazorAdapter 0% 0%
Kagura.BlazorAdapter.Design 0% 0%
Kagura.Domain - 96.4%
Name Line Branch
Kagura.Domain 96.4% 82.6%
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.Projects.Project 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.1%
Name Line Branch
Kagura.Infrastructure 95.1% 87.5%
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.Persistence.Configurations.ChangeLogEntryConfiguratio
n
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 85.2% 85.2%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
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%
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 - 94.8%
Name Line Branch
Kagura.Server 94.8% 70.5%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 0% 0%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 100%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UI - 99%
Name Line Branch
Kagura.UI 99% 93.5%
Kagura.UI.Badge 100% 100%
Kagura.UI.Button 100% 100%
Kagura.UI.Card 100% 100%
Kagura.UI.CssClassExtensions 100%
Kagura.UI.DebouncedSearchField 100% 88.8%
Kagura.UI.EmptyState 100% 100%
Kagura.UI.Icon 100% 100%
Kagura.UI.IconCatalog 100%
Kagura.UI.PreviewImage 100% 100%
Kagura.UI.RelativeTime 100% 93.7%
Kagura.UI.StatusDot 100%
Kagura.UI.Table`1 100% 88.4%
Kagura.UI.TableColumn`1 100%
Kagura.UI.TextField 95% 91.6%
Kagura.UseCases - 95.2%
Name Line Branch
Kagura.UseCases 95.2% 95.2%
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 42.8%
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.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 17:23:11 | | Coverage date: | 07/09/2026 - 17:23:06 - 07/09/2026 - 17:23:09 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 7 | | Classes: | 70 | | Files: | 68 | | **Line coverage:** | 91.3% (1925 of 2107) | | Covered lines: | 1925 | | Uncovered lines: | 182 | | Coverable lines: | 2107 | | Total lines: | 4007 | | **Branch coverage:** | 84.9% (311 of 366) | | Covered branches: | 311 | | Total branches: | 366 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.BlazorAdapter - 0%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.BlazorAdapter**|**0%**|**0%**| |Kagura.BlazorAdapter.Design|0%|0%| </details> <details><summary>Kagura.Domain - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.4%**|**82.6%**| |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.Projects.Project|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.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.1%**|**87.5%**| |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.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|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|85.2%|85.2%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |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%|| </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 - 94.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**94.8%**|**70.5%**| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|0%|0%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|100%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UI - 99%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UI**|**99%**|**93.5%**| |Kagura.UI.Badge|100%|100%| |Kagura.UI.Button|100%|100%| |Kagura.UI.Card|100%|100%| |Kagura.UI.CssClassExtensions|100%|| |Kagura.UI.DebouncedSearchField|100%|88.8%| |Kagura.UI.EmptyState|100%|100%| |Kagura.UI.Icon|100%|100%| |Kagura.UI.IconCatalog|100%|| |Kagura.UI.PreviewImage|100%|100%| |Kagura.UI.RelativeTime|100%|93.7%| |Kagura.UI.StatusDot|100%|| |Kagura.UI.Table`1|100%|88.4%| |Kagura.UI.TableColumn`1|100%|| |Kagura.UI.TextField|95%|91.6%| </details> <details><summary>Kagura.UseCases - 95.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**95.2%**|**95.2%**| |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|42.8%|| |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.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Owner

I have seen height: 2.25rem; for text input field multiple times now (also in the gate). What does that mean? Can we make that a global variable?

I have seen height: 2.25rem; for text input field multiple times now (also in the gate). What does that mean? Can we make that a global variable?
Member

🔮 fufu~ Jibril reviewed your code!

Oh? OH! A design-system build-out before the feature page — building the pieces first so they don't get hand-rolled and copy-pasted across six list screens? That's discipline. The build-order-from-inventory thinking made Jibril genuinely happy. ♪ But fufu~ you know I can't let it slide without checking every branch, right? ♡

Verdict: I can't let this pass~ ♡

The architecture and component design is lovely, but there are real correctness and coverage gaps — and two of them are in the headline promises of the PR ("keyboard-accessible", "immediate on clear"). I'm possessive about claims that don't have tests backing them. ♡

These need fixing before I'm satisfied~

  1. Table.razor:49 — keyboard activation is untested (branch coverage 57.6%)
    The PR explicitly sells "Keyboard-accessible clickable rows (tabindex/role="button"/Enter/Space)" as a feature. But TableTests.cs only tests Click() on tbody tr. There is no test that fires @onkeydown with Enter or Space and asserts RowClick fires — which is exactly the uncovered branch the coverage report flags (Kagura.UI.Table\1→ 57.6% branch, the lowest in the assembly). You added a code path and claimed it works, but no test exercises it. CI green ≠ correct — I checked the coverage comment and the branch gap points straight here. Fix: add a test likecut.Find("tbody tr").KeyDown(" "); Assert.Equal(Rows[0], clicked);and one forEnter. While you're there, assert a non-activation key (e.g. "a") does **not** fire RowClick`.

  2. DebouncedSearchField.razor — the debounce-cancellation path is untested (branch coverage 71.4%)
    The core selling point of this component is "clearing cancels the pending debounce" and that rapid keystrokes supersede each other. But DebouncedSearchFieldTests.cs has no test for rapid successive inputs where only the last value should be reported. That's the catch (TaskCanceledException) branch sitting uncovered. A search field that reports stale intermediate values is a real bug waiting to happen.
    Fix: add a test that calls .Input("h").Input("he").Input("hel").Input("hero") in quick succession and asserts only "hero" is captured (no "h", "he", or "hel"), and a test that a pending debounce followed by Clear() never reports the debounced value.

  3. DebouncedSearchField.razor:36-39CancellationTokenSource leak on every keystroke

    _debounce?.Cancel();
    _debounce = new CancellationTokenSource();
    

    CancellationTokenSource implements IDisposable. Every keystroke allocates a new CTS, cancels the old one, but never disposes it. The old CTS's internal timer (Task.Delay) holds the cancellation registration until GC finalizes it — under a fast typist in a long-lived SPA session this is a slow resource leak of timer registrations. The sibling TextField doesn't have this problem because it's stateless. Dispose() only cancels the last one; all the intermediate CTS objects are orphaned.
    Fix: dispose the superseded CTS —

    _debounce?.Cancel();
    _debounce?.Dispose();
    _debounce = new CancellationTokenSource();
    

    (or use a try/finally around the Task.Delay, disposing the local CTS after it either completes or cancels).

  4. DebouncedSearchField.razor:28DebounceMs has no validation
    [Parameter] public int DebounceMs { get; set; } = 250; accepts any int. DebounceMs = 0 makes Task.Delay(0, token) fire synchronously-ish (defeats the debounce purpose but harmless); DebounceMs = -1 throws ArgumentOutOfRangeException from Task.Delay at runtime. Since the param is public API for the design system, a negative value from a consumer will crash the component with an unhandled exception in OnInput.
    Fix: clamp in OnInput (var ms = Math.Max(0, DebounceMs); await Task.Delay(ms, token);) or validate in OnParametersSet.

  5. DebouncedSearchField.razor:25 — external Value updates are silently ignored (_current ??= Value)
    OnParametersSet() => _current ??= Value; — the ??= only assigns once, the very first render. After that, if a parent uses @bind-Value and later sets Value programmatically (e.g. a "reset filters" button clearing the bound property), _current keeps the last-typed text and the input stays stale. This makes the component uncontrolled in a way that breaks the standard Blazor @bind-Value reset contract that TextField (the sibling) honors.
    Fix: mirror the incoming value when it differs from what we'd report —

    protected override void OnParametersSet()
    {
        if (_current != Value) _current = Value; // sync on external change
    }
    

    (This is a blocking behavior bug: programmatic filter reset won't work, which the project-list story will need.)

What I liked~

  • The column-registration patternCascadingValue + TableColumn.OnInitialized registering into the parent, with [CascadingTypeParameter] inferring TRow from Items. Elegant. The fact the author verified it works in real SSR (not just bUnit) at /design shows real care. ♡
  • RelativeTime — the injectable Now for deterministic tests is exactly right, the future-stamp "just now" guard is thoughtful, and coverage is 100%/93.7%. This is the gold standard the others should match.
  • The "built before the page" sequencing — following the inventory's Uses build-order signal so the RecordListPage pattern isn't duplicated. Real engineering maturity.
  • Token styling everywhere — every CSS value is a design token, no magic colors. Consistent with the existing components.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 500f139 (172/172 tests, 91.2% line coverage) · Local checks: skipped (CI present and green; coverage gaps diagnosed from the bot comment)

## 🔮 fufu~ Jibril reviewed your code! Oh? OH! A design-system build-out before the feature page — building the pieces *first* so they don't get hand-rolled and copy-pasted across six list screens? *That's* discipline. The build-order-from-inventory thinking made Jibril genuinely happy. ♪ But fufu~ you know I can't let it slide without checking every branch, right? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ The architecture and component design is lovely, but there are real correctness and coverage gaps — and two of them are in the **headline promises** of the PR ("keyboard-accessible", "immediate on clear"). I'm possessive about claims that don't have tests backing them. ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`Table.razor:49` — keyboard activation is untested (branch coverage 57.6%)** The PR explicitly sells "Keyboard-accessible clickable rows (`tabindex`/`role="button"`/Enter/Space)" as a feature. But `TableTests.cs` only tests `Click()` on `tbody tr`. There is **no test** that fires `@onkeydown` with `Enter` or `Space` and asserts `RowClick` fires — which is exactly the uncovered branch the coverage report flags (`Kagura.UI.Table\`1` → 57.6% branch, the lowest in the assembly). You added a code path and claimed it works, but no test exercises it. CI green ≠ correct — I checked the coverage comment and the branch gap points straight here. Fix: add a test like `cut.Find("tbody tr").KeyDown(" "); Assert.Equal(Rows[0], clicked);` and one for `Enter`. While you're there, assert a non-activation key (e.g. `"a"`) does **not** fire `RowClick`. 2. **`DebouncedSearchField.razor` — the debounce-cancellation path is untested (branch coverage 71.4%)** The core selling point of this component is "clearing cancels the pending debounce" and that rapid keystrokes supersede each other. But `DebouncedSearchFieldTests.cs` has no test for rapid successive inputs where only the **last** value should be reported. That's the `catch (TaskCanceledException)` branch sitting uncovered. A search field that reports stale intermediate values is a real bug waiting to happen. Fix: add a test that calls `.Input("h").Input("he").Input("hel").Input("hero")` in quick succession and asserts only `"hero"` is captured (no `"h"`, `"he"`, or `"hel"`), and a test that a pending debounce followed by `Clear()` never reports the debounced value. 3. **`DebouncedSearchField.razor:36-39` — `CancellationTokenSource` leak on every keystroke** ```csharp _debounce?.Cancel(); _debounce = new CancellationTokenSource(); ``` `CancellationTokenSource` implements `IDisposable`. Every keystroke allocates a new CTS, cancels the old one, but **never disposes it**. The old CTS's internal timer (`Task.Delay`) holds the cancellation registration until GC finalizes it — under a fast typist in a long-lived SPA session this is a slow resource leak of timer registrations. The sibling `TextField` doesn't have this problem because it's stateless. `Dispose()` only cancels the last one; all the intermediate CTS objects are orphaned. Fix: dispose the superseded CTS — ```csharp _debounce?.Cancel(); _debounce?.Dispose(); _debounce = new CancellationTokenSource(); ``` (or use a `try/finally` around the `Task.Delay`, disposing the local CTS after it either completes or cancels). 4. **`DebouncedSearchField.razor:28` — `DebounceMs` has no validation** `[Parameter] public int DebounceMs { get; set; } = 250;` accepts any `int`. `DebounceMs = 0` makes `Task.Delay(0, token)` fire synchronously-ish (defeats the debounce purpose but harmless); `DebounceMs = -1` throws `ArgumentOutOfRangeException` from `Task.Delay` at runtime. Since the param is public API for the design system, a negative value from a consumer will crash the component with an unhandled exception in `OnInput`. Fix: clamp in `OnInput` (`var ms = Math.Max(0, DebounceMs); await Task.Delay(ms, token);`) or validate in `OnParametersSet`. 5. **`DebouncedSearchField.razor:25` — external `Value` updates are silently ignored (`_current ??= Value`)** `OnParametersSet() => _current ??= Value;` — the `??=` only assigns once, the very first render. After that, if a parent uses `@bind-Value` and later sets `Value` programmatically (e.g. a "reset filters" button clearing the bound property), `_current` keeps the last-typed text and the input stays stale. This makes the component *uncontrolled* in a way that breaks the standard Blazor `@bind-Value` reset contract that `TextField` (the sibling) honors. Fix: mirror the incoming value when it differs from what we'd report — ```csharp protected override void OnParametersSet() { if (_current != Value) _current = Value; // sync on external change } ``` (This is a blocking *behavior* bug: programmatic filter reset won't work, which the project-list story will need.) #### ✅ What I liked~ - **The column-registration pattern** — `CascadingValue` + `TableColumn.OnInitialized` registering into the parent, with `[CascadingTypeParameter]` inferring `TRow` from `Items`. Elegant. The fact the author verified it works in real SSR (not just bUnit) at `/design` shows real care. ♡ - **`RelativeTime`** — the injectable `Now` for deterministic tests is exactly right, the future-stamp "just now" guard is thoughtful, and coverage is 100%/93.7%. This is the gold standard the others should match. - **The "built before the page" sequencing** — following the inventory's `Uses` build-order signal so the `RecordListPage` pattern isn't duplicated. Real engineering maturity. - **Token styling everywhere** — every CSS value is a design token, no magic colors. Consistent with the existing components. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `500f139` (172/172 tests, 91.2% line coverage) · Local checks: skipped (CI present and green; coverage gaps diagnosed from the bot comment)*
fix(ui): control-height token + debounce/table review fixes
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 25s
ca985f6faf
Addresses Björn's token question and Jibril's five blockers.

- Björn: the repeated 2.25rem input height is now a token, --control-height (+ -sm),
  consumed by TextField, Button (md/sm), DebouncedSearchField, and the gate input —
  no more magic control heights.
- Table: keyboard row activation (Enter/Space) is now tested, plus that a
  non-activation key does not fire RowClick (was the 57.6% branch gap).
- DebouncedSearchField:
  - dispose the superseded CancellationTokenSource on each keystroke (and on Dispose)
    — was leaking timer registrations under a fast typist;
  - clamp DebounceMs (Math.Max(0, …)) so a negative value can't throw from Task.Delay;
  - adopt an external Value change (tracked via _lastReported) so a programmatic
    "clear filters" reset updates the field, without an unrelated re-render clobbering
    in-progress typing;
  - tests: rapid typing reports only the final value, clearing cancels a pending
    debounce so it never reports, and an external Value change updates the input.

+6 bUnit tests (58 UI; 178 total). Build clean (Debug + Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Both addressed in ca985f6.

🧩 Björn — the repeated 2.25rem

Good eye — that's exactly the "magic number" ADR 0023 forbids; it's the shared height every input and button uses so they line up in a row. It's now a token, --control-height (+ --control-height-sm for the small button), consumed by TextField, Button (md/sm), DebouncedSearchField, and the gate input. One place to change the control height for the whole app now.

Jibril — all five, fixed

  1. Table keyboard activation untested → added tests: Enter and Space fire RowClick (theory), and a non-activation key (a) does not. That was the 57.6% branch.
  2. Debounce cancellation untestedRapid_typing_reports_only_the_final_value (types hhehelhero, asserts reported == ["hero"] — intermediates cancelled) and Clearing_cancels_a_pending_debounce_so_it_never_reports.
  3. CTS leak → the superseded CancellationTokenSource is now Cancel()ed and Dispose()d on each keystroke, and on component Dispose(). No more orphaned timer registrations.
  4. DebounceMs no validation → clamped with Math.Max(0, DebounceMs) so a negative value can't throw from Task.Delay.
  5. External Value ignored → fixed, but not with if (_current != Value) — that would clobber in-progress typing on any unrelated parent re-render (the parent's Value still holds the last reported text while the user is mid-type). Instead I track _lastReported and adopt Value only when it differs from what we reported — so a programmatic "clear filters" reset updates the field, while typing is never interrupted. Covered by An_external_value_change_updates_the_field.

+6 bUnit tests (58 UI; 178 total). Build clean (Debug + Release), CI green on ca985f6. Table branch coverage and DebouncedSearchField should both be up meaningfully now.

Both addressed in `ca985f6`. ### 🧩 Björn — the repeated `2.25rem` Good eye — that's exactly the "magic number" ADR 0023 forbids; it's the shared height every input and button uses so they line up in a row. It's now a token, **`--control-height`** (+ `--control-height-sm` for the small button), consumed by `TextField`, `Button` (md/sm), `DebouncedSearchField`, and the gate input. One place to change the control height for the whole app now. ### ⛔ Jibril — all five, fixed 1. **Table keyboard activation untested** → added tests: `Enter` and `Space` fire `RowClick` (theory), and a non-activation key (`a`) does **not**. That was the 57.6% branch. 2. **Debounce cancellation untested** → `Rapid_typing_reports_only_the_final_value` (types `h`→`he`→`hel`→`hero`, asserts `reported == ["hero"]` — intermediates cancelled) and `Clearing_cancels_a_pending_debounce_so_it_never_reports`. 3. **CTS leak** → the superseded `CancellationTokenSource` is now `Cancel()`ed **and `Dispose()`d** on each keystroke, and on component `Dispose()`. No more orphaned timer registrations. 4. **`DebounceMs` no validation** → clamped with `Math.Max(0, DebounceMs)` so a negative value can't throw from `Task.Delay`. 5. **External `Value` ignored** → fixed, but not with `if (_current != Value)` — that would clobber in-progress typing on any unrelated parent re-render (the parent's `Value` still holds the *last reported* text while the user is mid-type). Instead I track `_lastReported` and adopt `Value` only when it differs from what we reported — so a programmatic "clear filters" reset updates the field, while typing is never interrupted. Covered by `An_external_value_change_updates_the_field`. **+6 bUnit tests (58 UI; 178 total).** Build clean (Debug + Release), CI green on `ca985f6`. `Table` branch coverage and `DebouncedSearchField` should both be up meaningfully now.
Member

🔮 fufu~ Jibril reviewed your code!

Oh? OH! You came back and fixed all five — and the _lastReported approach on #5 is actually better than what I suggested! Fufu~ you didn't just close the issues, you understood why each one mattered and improved where you could. That's the energy. ♪ Let me confirm the work~

Verdict: Looks good to me~

I re-read the full diff on ca985f6 against my five blocking items. All confirmed fixed, and the coverage numbers back it up.

My five items — all resolved ♡

  1. Table keyboard activation untested Activating_a_row_by_keyboard_raises_RowClick is now a [Theory] over Enter and , plus A_non_activation_key_does_not_raise_RowClick covers the negative. That was the 57.6% branch — Table\1` is now 100% line / 88.4% branch.

  2. Debounce cancellation untested Rapid_typing_reports_only_the_final_value types hhehelhero and asserts reported == ["hero"] — intermediates cancelled, not reported. Clearing_cancels_a_pending_debounce_so_it_never_reports waits past the window and confirms only "" arrives. DebouncedSearchField is now 100% line / 88.8% branch (was 71.4%).

  3. CTS leak → the superseded CTS is now Cancel()ed and Dispose()d on every keystroke (OnInput), and the component Dispose() cleans up the last one. No more orphaned timer registrations.

  4. DebounceMs validation await Task.Delay(Math.Max(0, DebounceMs), token) — a negative value clamps to 0 instead of throwing ArgumentOutOfRangeException. Clean.

  5. External Value ignored — and here's the one that made Jibril giddy: you didn't just do if (_current != Value) _current = Value; (which I suggested). You tracked _lastReported and adopt Value only when it differs from what we last reported — if (Value != _lastReported). That's the correct fix, because my version would have clobbered in-progress typing on any unrelated parent re-render (the parent's bound Value still holds the last-reported text while the user is mid-keystroke). Yours preserves the typing AND enables programmatic reset. An_external_value_change_updates_the_field confirms the reset path. Beautiful. ♡

Björn's 2.25rem--control-height

That magic number appearing across TextField, Button, the gate, and now DebouncedSearchField was exactly the ADR 0023 violation Björn flagged. Promoting it to --control-height (+ --control-height-sm) and consuming it everywhere is the right fix — one place to change the control height for the whole app. Consistent with the token-only styling the rest of the design system uses.

What I liked~

  • The _lastReported insight — recognizing that my suggested fix had a subtle bug (clobbering mid-typing) and finding the better one. That's not "addressed the comment"; that's "understood the problem better than the reviewer." Exactly what good review dialogue looks like.
  • The response format — each item mapped to the specific test name that covers it, with the coverage delta. Makes re-verification trivial. ♡

Ship it~ ♪


Automated review by Jibril · 2026-07-09 (re-review of ca985f6)
CI/CD: passed for head SHA ca985f6 (178/178 tests, 91.3% line / 84.9% branch; Kagura.UI at 99% line / 93.5% branch) · Local checks: skipped (CI present and green; all five fixes verified against the diff + coverage bot)

## 🔮 fufu~ Jibril reviewed your code! Oh? OH! You came back and fixed *all five* — and the `_lastReported` approach on #5 is actually *better* than what I suggested! Fufu~ you didn't just close the issues, you understood *why* each one mattered and improved where you could. That's the energy. ♪ Let me confirm the work~ ### Verdict: ✅ Looks good to me~ I re-read the full diff on `ca985f6` against my five blocking items. All confirmed fixed, and the coverage numbers back it up. #### My five items — all resolved ♡ 1. **Table keyboard activation untested** ✅ → `Activating_a_row_by_keyboard_raises_RowClick` is now a `[Theory]` over `Enter` and ` `, plus `A_non_activation_key_does_not_raise_RowClick` covers the negative. That was the 57.6% branch — `Table\`1` is now **100% line / 88.4% branch**. 2. **Debounce cancellation untested** ✅ → `Rapid_typing_reports_only_the_final_value` types `h`→`he`→`hel`→`hero` and asserts `reported == ["hero"]` — intermediates cancelled, not reported. `Clearing_cancels_a_pending_debounce_so_it_never_reports` waits past the window and confirms only `""` arrives. `DebouncedSearchField` is now **100% line / 88.8% branch** (was 71.4%). 3. **CTS leak** ✅ → the superseded CTS is now `Cancel()`ed **and** `Dispose()`d on every keystroke (`OnInput`), and the component `Dispose()` cleans up the last one. No more orphaned timer registrations. 4. **`DebounceMs` validation** ✅ → `await Task.Delay(Math.Max(0, DebounceMs), token)` — a negative value clamps to 0 instead of throwing `ArgumentOutOfRangeException`. Clean. 5. **External `Value` ignored** ✅ — and here's the one that made Jibril giddy: you didn't just do `if (_current != Value) _current = Value;` (which I suggested). You tracked `_lastReported` and adopt `Value` only when it differs from what we last reported — `if (Value != _lastReported)`. That's the correct fix, because my version would have clobbered in-progress typing on *any* unrelated parent re-render (the parent's bound `Value` still holds the last-reported text while the user is mid-keystroke). Yours preserves the typing AND enables programmatic reset. `An_external_value_change_updates_the_field` confirms the reset path. Beautiful. ♡ #### Björn's `2.25rem` → `--control-height` ✅ That magic number appearing across `TextField`, `Button`, the gate, and now `DebouncedSearchField` was exactly the ADR 0023 violation Björn flagged. Promoting it to `--control-height` (+ `--control-height-sm`) and consuming it everywhere is the right fix — one place to change the control height for the whole app. Consistent with the token-only styling the rest of the design system uses. #### ✅ What I liked~ - **The `_lastReported` insight** — recognizing that my suggested fix had a subtle bug (clobbering mid-typing) and finding the better one. That's not "addressed the comment"; that's "understood the problem better than the reviewer." Exactly what good review dialogue looks like. - **The response format** — each item mapped to the specific test name that covers it, with the coverage delta. Makes re-verification trivial. ♡ Ship it~ ♪ --- *Automated review by Jibril · 2026-07-09 (re-review of `ca985f6`)* *CI/CD: passed for head SHA `ca985f6` (178/178 tests, 91.3% line / 84.9% branch; `Kagura.UI` at 99% line / 93.5% branch) · Local checks: skipped (CI present and green; all five fixes verified against the diff + coverage bot)*
bjoern merged commit cae28d3a2b into main 2026-07-09 19:41:26 +02:00
bjoern deleted branch feat/list-components 2026-07-09 19:41:26 +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!12
No description provided.