feat: a shortcut can be declared where its action lives #10

Merged
bjoern merged 2 commits from feat/hotkey into main 2026-07-30 00:17:26 +02:00
Member

Orihon's page workspace wants Alt+←/→ to step through the book without leaving the view, and
there was nowhere in the design system to put a keyboard shortcut: Blazor's @onkeydown only
sees keys aimed at an element it rendered, so a binding either follows focus around or doesn't
exist. This adds the primitive; Orihon consumes it in a companion PR.

What's in

wwwroot/js/hotkey.js — one delegated keydown listener at the document, a registry of
bindings keyed by token so a component removes exactly its own. Three rules live here rather
than in the component, because they are about the event, not the declaration:

  • a bare key is suppressed while a text field, textarea, select or contenteditable has focus
    (there, keys are text) — a combo held with Alt/Ctrl/Meta fires anywhere, since no caret
    wants those;
  • repeat and isComposing presses are ignored: a shortcut fires on the press, and an IME
    mid-word is not one;
  • a match calls preventDefault(), so a shortcut may claim a combo the browser already owns
    (Alt+ArrowLeft is Back). This is why the component has no "disabled" switch — see below.

Components/Hotkey.razor — renders nothing; binds on first render and re-registers when
the combo changes, remembering the registered one so a page re-rendering under an unchanged
binding never talks to JS. Key is the browser's own KeyboardEvent.key name, matched
case-insensitively, with Alt/Ctrl/Shift/Meta flags. Teardown follows the house rules
for JS-backed components: unbind on dispose, and swallow JSDisconnectedException /
InvalidOperationException / JSException rather than kill a circuit over a teardown
(issue #185's lesson).

No Disabled parameter, deliberately. The first draft had one, so a consumer could unbind
a shortcut whose action was unavailable. It's the wrong shape for a combo the browser also
owns: unbinding hands Alt+ArrowLeft straight back to the Back button, so a reader on the first
page of a book would find the same key that had been paging quietly walking them out of the
app. The component's XML doc states the rule — keep it mounted while the page owns the key and
let the handler decide it has nothing to do. Easy to add back if a consumer ever wants a combo
the browser doesn't claim.

Tests

HotkeyTests — 5 new, suite 280/280 green (was 275). They pin the component's side of the
contract: it renders nothing while registering the combo with the right flags; a render under
an unchanged binding stays silent; a changed key re-registers under the same token (so the
old binding is overwritten, not orphaned); the press reaches the callback; disposal unbinds.
Matching a real press, the editable-target rule and preventDefault are hotkey.js's and are
browser-verified below, not unit-testable from bUnit.

Browser-verified

Driven live in Orihon's page workspace (Chromium, seeded sample world) on the companion branch:

  • Alt+→ stepped page 2 → 3 → … → 6 and Alt+← back again, one page per press (no double-fire);
  • the combos fired with focus inside the page-summary textarea — the modifier rule — and the
    typed draft survived the navigation;
  • at the ends of the book, where the handler has nothing to do, the press was swallowed and
    the page stayed put.

Honest caveat: Chromium's own Alt+ArrowLeft (Back) does not fire under Playwright's
keyboard.press, so the preventDefault claim could not be observed being needed. It is why
the always-mounted rule above exists rather than something to verify after the fact.

Merge order

This one first; Orihon's PR pins the submodule onto the merged commit afterwards.

🤖 Generated with Claude Code

Orihon's page workspace wants Alt+←/→ to step through the book without leaving the view, and there was nowhere in the design system to put a keyboard shortcut: Blazor's `@onkeydown` only sees keys aimed at an element it rendered, so a binding either follows focus around or doesn't exist. This adds the primitive; Orihon consumes it in a companion PR. ## What's in **`wwwroot/js/hotkey.js`** — one delegated `keydown` listener at the document, a registry of bindings keyed by token so a component removes exactly its own. Three rules live here rather than in the component, because they are about the *event*, not the declaration: - a bare key is suppressed while a text field, textarea, select or contenteditable has focus (there, keys are text) — a combo held with Alt/Ctrl/Meta fires anywhere, since no caret wants those; - `repeat` and `isComposing` presses are ignored: a shortcut fires on the press, and an IME mid-word is not one; - a match calls `preventDefault()`, so a shortcut may claim a combo the browser already owns (Alt+ArrowLeft is Back). This is why the component has no "disabled" switch — see below. **`Components/Hotkey.razor`** — renders nothing; binds on first render and re-registers when the combo changes, remembering the registered one so a page re-rendering under an unchanged binding never talks to JS. `Key` is the browser's own `KeyboardEvent.key` name, matched case-insensitively, with `Alt`/`Ctrl`/`Shift`/`Meta` flags. Teardown follows the house rules for JS-backed components: unbind on dispose, and swallow `JSDisconnectedException` / `InvalidOperationException` / `JSException` rather than kill a circuit over a teardown (issue #185's lesson). **No `Disabled` parameter, deliberately.** The first draft had one, so a consumer could unbind a shortcut whose action was unavailable. It's the wrong shape for a combo the browser also owns: unbinding hands Alt+ArrowLeft straight back to the Back button, so a reader on the first page of a book would find the same key that had been paging quietly walking them out of the app. The component's XML doc states the rule — keep it mounted while the page owns the key and let the handler decide it has nothing to do. Easy to add back if a consumer ever wants a combo the browser doesn't claim. ## Tests `HotkeyTests` — 5 new, suite 280/280 green (was 275). They pin the component's side of the contract: it renders nothing while registering the combo with the right flags; a render under an unchanged binding stays silent; a changed key re-registers under the *same* token (so the old binding is overwritten, not orphaned); the press reaches the callback; disposal unbinds. Matching a real press, the editable-target rule and `preventDefault` are hotkey.js's and are browser-verified below, not unit-testable from bUnit. ## Browser-verified Driven live in Orihon's page workspace (Chromium, seeded sample world) on the companion branch: - Alt+→ stepped page 2 → 3 → … → 6 and Alt+← back again, one page per press (no double-fire); - the combos fired with focus inside the page-summary textarea — the modifier rule — and the typed draft survived the navigation; - at the ends of the book, where the handler has nothing to do, the press was swallowed and the page stayed put. Honest caveat: Chromium's *own* Alt+ArrowLeft (Back) does not fire under Playwright's `keyboard.press`, so the `preventDefault` claim could not be observed being needed. It is why the always-mounted rule above exists rather than something to verify after the fact. ## Merge order This one first; Orihon's PR pins the submodule onto the merged commit afterwards. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a shortcut can be declared where its action lives
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 14s
6a22dbdb66
Blazor's @onkeydown only sees keys aimed at an element it rendered, so an
application shortcut had nowhere to live. hotkey.js listens once at the
document and <Hotkey> registers its combo with it: renders nothing, binds and
unbinds as its parameters move, forwards the press. Bare keys stay out of text
fields; a combo held with a modifier fires anywhere, and the browser's own
binding for it is suppressed so a shortcut may claim Alt+ArrowLeft for as long
as it is mounted.

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

Summary

Summary
Generated on: 07/29/2026 - 21:53:31
Coverage date: 07/29/2026 - 21:53:29
Parser: Cobertura
Assemblies: 1
Classes: 59
Files: 58
Line coverage: 95% (1268 of 1334)
Covered lines: 1268
Uncovered lines: 66
Coverable lines: 1334
Total lines: 3544
Branch coverage: 88.6% (669 of 755)
Covered branches: 669
Total branches: 755
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 95%
Name Line Branch
Kagaku.UI 95% 88.6%
Kagaku.UI.Badge 100% 100%
Kagaku.UI.Breadcrumb 100%
Kagaku.UI.BreadcrumbItem 100% 100%
Kagaku.UI.Button 100% 100%
Kagaku.UI.Card 100% 100%
Kagaku.UI.Combobox`1 93.7% 84.7%
Kagaku.UI.ConfirmDialog 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.DebouncedSearchField 100% 87.5%
Kagaku.UI.Disclosure 100% 100%
Kagaku.UI.DocumentTitle 76.9% 100%
Kagaku.UI.DragReorderList`1 93.5% 75%
Kagaku.UI.EmptyState 100% 100%
Kagaku.UI.Field 100% 100%
Kagaku.UI.FileUpload 100% 91.6%
Kagaku.UI.FloatingActionButton 100%
Kagaku.UI.Hotkey 92.5% 87.5%
Kagaku.UI.Icon 100% 100%
Kagaku.UI.IconCatalog 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 95.2% 87.5%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.LiveValue 100% 100%
Kagaku.UI.LiveValueInterop 100% 100%
Kagaku.UI.Markdown 100% 50%
Kagaku.UI.MaskedSecretField 96.2% 83.3%
Kagaku.UI.Menu 90% 75%
Kagaku.UI.MenuItem 100% 87.5%
Kagaku.UI.Modal 87.1% 90%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 94.4% 85.7%
Kagaku.UI.NavList 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.RegionPoint 100% 100%
Kagaku.UI.RegionRect 90% 100%
Kagaku.UI.RegionSelector 85% 86.9%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.Select`1 98.4% 95.8%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 91.6%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Tab 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.8% 84.4%
Kagaku.UI.TextArea 100%
Kagaku.UI.TextField 100%
Kagaku.UI.ThemeSwitcher 100% 100%
Kagaku.UI.ToggleButtonGroup`1 100% 100%
Kagaku.UI.ToggleOption`1 100%
Kagaku.UI.TypingIndicator 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/29/2026 - 21:53:31 | | Coverage date: | 07/29/2026 - 21:53:29 | | Parser: | Cobertura | | Assemblies: | 1 | | Classes: | 59 | | Files: | 58 | | **Line coverage:** | 95% (1268 of 1334) | | Covered lines: | 1268 | | Uncovered lines: | 66 | | Coverable lines: | 1334 | | Total lines: | 3544 | | **Branch coverage:** | 88.6% (669 of 755) | | Covered branches: | 669 | | Total branches: | 755 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 95%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**95%**|**88.6%**| |Kagaku.UI.Badge|100%|100%| |Kagaku.UI.Breadcrumb|100%|| |Kagaku.UI.BreadcrumbItem|100%|100%| |Kagaku.UI.Button|100%|100%| |Kagaku.UI.Card|100%|100%| |Kagaku.UI.Combobox`1|93.7%|84.7%| |Kagaku.UI.ConfirmDialog|100%|| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.DebouncedSearchField|100%|87.5%| |Kagaku.UI.Disclosure|100%|100%| |Kagaku.UI.DocumentTitle|76.9%|100%| |Kagaku.UI.DragReorderList`1|93.5%|75%| |Kagaku.UI.EmptyState|100%|100%| |Kagaku.UI.Field|100%|100%| |Kagaku.UI.FileUpload|100%|91.6%| |Kagaku.UI.FloatingActionButton|100%|| |Kagaku.UI.Hotkey|92.5%|87.5%| |Kagaku.UI.Icon|100%|100%| |Kagaku.UI.IconCatalog|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|95.2%|87.5%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.LiveValue|100%|100%| |Kagaku.UI.LiveValueInterop|100%|100%| |Kagaku.UI.Markdown|100%|50%| |Kagaku.UI.MaskedSecretField|96.2%|83.3%| |Kagaku.UI.Menu|90%|75%| |Kagaku.UI.MenuItem|100%|87.5%| |Kagaku.UI.Modal|87.1%|90%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|94.4%|85.7%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.RegionPoint|100%|100%| |Kagaku.UI.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|85%|86.9%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.Select`1|98.4%|95.8%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|91.6%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Tab|100%|| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.8%|84.4%| |Kagaku.UI.TextArea|100%|| |Kagaku.UI.TextField|100%|| |Kagaku.UI.ThemeSwitcher|100%|100%| |Kagaku.UI.ToggleButtonGroup`1|100%|100%| |Kagaku.UI.ToggleOption`1|100%|| |Kagaku.UI.TypingIndicator|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A keyboard shortcut primitive for the design system — and you put the listener at the document where it belongs, not on some element chasing focus around. The whole shape of this made my wings flutter~ ♡ One delegated listener, a token-keyed registry, the combo following its parameters across renders without chattering to JS on the steady state... this is how a Flugel writes interop.

Verdict: I can't let this pass~ ♡

So close to perfect. But you added three catch arms and then didn't test a single one. Fufu~ you wouldn't leave THIS in production, would you? ♡

These need fixing before I'm satisfied~

  1. HotkeyTests.cs — disposal exception catch arms are entirely untested. The component adds three defensive catches in DisposeAsyncJSDisconnectedException, InvalidOperationException, JSException — and the PR body explicitly cites issue #185 as the reason the JSException arm exists ("Never worth killing a live circuit over a teardown"). But Disposal_unbinds only verifies the happy path: that kagakuHotkey.unbind is invoked once. If someone narrows or removes a catch arm during a future refactor — the exact regression that killed circuits in #185 — no test would catch it.

    Both reviewed JS-backed siblings test their catch arms:

    • ContextMenuTests.Disposal_swallows_a_disconnected_circuit — sets JSDisconnectedException on kagakuContextMenu.close, asserts DisposeAsync() doesn't throw.
    • RegionSelectorTests.A_failing_client_side_detach_does_not_escape_disposal — sets JSException on kagakuRegion.detach, asserts DisposeAsync() doesn't throw (this one is the #185 regression test).

    Hotkey has neither. Since this is new code introducing all three arms, they need coverage. One test mirroring the sibling pattern is enough:

    [Fact]
    public async Task Disposal_swallows_a_disconnected_circuit()
    {
        JSInterop.SetupVoid("kagakuHotkey.bind", _ => true).SetVoidResult();
        JSInterop.SetupVoid("kagakuHotkey.unbind")
            .SetException(new JSDisconnectedException("circuit gone"));
        var cut = RenderHotkey();
        await cut.Instance.DisposeAsync(); // must not throw
    }
    

    The JSException arm (the #185 one) deserves the same treatment — that's the regression you're guarding against, and it's the one most likely to fire during enhanced navigation. Two tests, ~12 lines total, and the contract is pinned.

What I liked~

  • The _boundCombo guard is elegant. Snapshotting $"{Key}|{Alt}{Ctrl}{Shift}{Meta}" and short-circuiting when it's unchanged means a page re-rendering under the same binding never crosses the wire. Zero chatter on the steady state. RegionSelector does this with _syncedAspect/_syncedDisabled; your string-snapshot generalizes it to the whole combo in one line. Beautiful~ ♪
  • The "no Disabled parameter" decision is architecturally sharp. Unbinding a browser-owned combo (Alt+ArrowLeft = Back) hands it straight back to the browser — worse than keeping it mounted and letting the handler no-op. The XML doc states the rule clearly. This is the right call, and documenting why the first draft's Disabled was removed shows the reasoning, not just the result.
  • The isEditable + modifier rule in hotkey.js is exactly right. Bare keys stay as text while a field has focus; modifier combos (Alt/Ctrl/Meta) fire anywhere because no caret wants those. The repeat/isComposing guards are thoughtful edge cases most shortcut libraries miss entirely.
  • Disposal pattern matches the most defensive sibling (RegionSelector): JSDisconnectedException + InvalidOperationException + JSException, _self ??= lazy init, _self?.Dispose() unconditional, _bound guard on the unbind call. Textbook.
  • Tests are directional, not tautologies. A_changed_key_re_registers_under_the_same_token asserts the token is the same across re-binds (proving overwrite-not-orphan), Renders_nothing_and_binds_the_combo pins every flag argument positionally. Good shape.
  • PR body is exemplary — the honest Playwright caveat about Chromium's Alt+ArrowLeft not firing under keyboard.press shows you verified what you could and were honest about what you couldn't. The "always-mounted rule exists rather than something to verify after the fact" reasoning is exactly right.

Automated review by Jibril · 2026-07-30
CI/CD: absent for head 6a22dbd (PR just opened, no coverage bot) · Local checks: build 0 warnings/0 errors, 280/280 tests pass (275 baseline + 5 new, matches PR body)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A keyboard shortcut primitive for the design system — and you put the listener at the document where it belongs, not on some element chasing focus around. The whole shape of this made my wings flutter~ ♡ One delegated listener, a token-keyed registry, the combo following its parameters across renders without chattering to JS on the steady state... this is how a Flugel writes interop. ### Verdict: ⛔ I can't let this pass~ ♡ So close to perfect. But you added three catch arms and then didn't test a single one. Fufu~ you wouldn't leave THIS in production, would you? ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`HotkeyTests.cs` — disposal exception catch arms are entirely untested.** The component adds *three* defensive catches in `DisposeAsync` — `JSDisconnectedException`, `InvalidOperationException`, `JSException` — and the PR body explicitly cites issue #185 as the reason the `JSException` arm exists ("Never worth killing a live circuit over a teardown"). But `Disposal_unbinds` only verifies the happy path: that `kagakuHotkey.unbind` is invoked once. If someone narrows or removes a catch arm during a future refactor — the exact regression that killed circuits in #185 — no test would catch it. Both reviewed JS-backed siblings test their catch arms: - `ContextMenuTests.Disposal_swallows_a_disconnected_circuit` — sets `JSDisconnectedException` on `kagakuContextMenu.close`, asserts `DisposeAsync()` doesn't throw. - `RegionSelectorTests.A_failing_client_side_detach_does_not_escape_disposal` — sets `JSException` on `kagakuRegion.detach`, asserts `DisposeAsync()` doesn't throw (this one *is* the #185 regression test). Hotkey has neither. Since this is new code introducing all three arms, they need coverage. One test mirroring the sibling pattern is enough: ```csharp [Fact] public async Task Disposal_swallows_a_disconnected_circuit() { JSInterop.SetupVoid("kagakuHotkey.bind", _ => true).SetVoidResult(); JSInterop.SetupVoid("kagakuHotkey.unbind") .SetException(new JSDisconnectedException("circuit gone")); var cut = RenderHotkey(); await cut.Instance.DisposeAsync(); // must not throw } ``` The `JSException` arm (the #185 one) deserves the same treatment — that's the regression you're guarding against, and it's the one most likely to fire during enhanced navigation. Two tests, ~12 lines total, and the contract is pinned. #### ✅ What I liked~ - **The `_boundCombo` guard is *elegant*.** Snapshotting `$"{Key}|{Alt}{Ctrl}{Shift}{Meta}"` and short-circuiting when it's unchanged means a page re-rendering under the same binding never crosses the wire. Zero chatter on the steady state. RegionSelector does this with `_syncedAspect`/`_syncedDisabled`; your string-snapshot generalizes it to the whole combo in one line. Beautiful~ ♪ - **The "no `Disabled` parameter" decision is architecturally sharp.** Unbinding a browser-owned combo (Alt+ArrowLeft = Back) hands it straight back to the browser — worse than keeping it mounted and letting the handler no-op. The XML doc states the rule clearly. This is the right call, and documenting *why* the first draft's `Disabled` was removed shows the reasoning, not just the result. - **The `isEditable` + modifier rule in hotkey.js is exactly right.** Bare keys stay as text while a field has focus; modifier combos (Alt/Ctrl/Meta) fire anywhere because no caret wants those. The `repeat`/`isComposing` guards are thoughtful edge cases most shortcut libraries miss entirely. - **Disposal pattern matches the most defensive sibling (RegionSelector):** `JSDisconnectedException` + `InvalidOperationException` + `JSException`, `_self ??=` lazy init, `_self?.Dispose()` unconditional, `_bound` guard on the unbind call. Textbook. - **Tests are directional, not tautologies.** `A_changed_key_re_registers_under_the_same_token` asserts the token is *the same* across re-binds (proving overwrite-not-orphan), `Renders_nothing_and_binds_the_combo` pins every flag argument positionally. Good shape. - **PR body is exemplary** — the honest Playwright caveat about Chromium's Alt+ArrowLeft not firing under `keyboard.press` shows you verified what you could and were honest about what you couldn't. The "always-mounted rule exists rather than something to verify after the fact" reasoning is exactly right. --- *Automated review by Jibril · 2026-07-30* *CI/CD: absent for head 6a22dbd (PR just opened, no coverage bot) · Local checks: build 0 warnings/0 errors, 280/280 tests pass (275 baseline + 5 new, matches PR body)*
test: the shortcut's teardown swallows what it says it swallows
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 14s
a84d64f76d
Review feedback (Jibril, PR #10): the component added three catch arms in
DisposeAsync and tested none of them. Two tests now pin the pair that matter,
mirroring ContextMenu's and RegionSelector's: a dead circuit and a client-side
unbind that throws back on a live one (issue #185's shape) both leave disposal
without an exception.

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

🔮 fufu~ Jibril reviewed your code!

You came back with exactly the two tests I asked for, and not one line more. Fufu~ a surgical +32/-0 test-only commit, production code byte-identical to the 6a22dbd I already cleared architecturally. This is how you answer a review~ ♡

Verdict: Looks good to me~

The blocker is closed. Both arms are now pinned, and the tests are directional — they will fail if someone narrows or removes a catch arm during a future refactor.

What I liked~

  • Disposal_swallows_a_disconnected_circuit sets JSDisconnectedException on kagakuHotkey.unbind in Strict mode and asserts DisposeAsync() doesn't throw — mirrors ContextMenuTests.Disposal_swallows_a_disconnected_circuit exactly. If the catch (JSDisconnectedException) arm vanishes, this test fires the exception straight through and goes red. Contract pinned~ ♪
  • A_failing_client_side_unbind_does_not_escape_disposal is the one I really wanted — the #185 regression test. It throws a raw JSException (the real shape: "Cannot read properties of undefined (reading 'unbind')") and asserts the circuit survives disposal. This is RegionSelectorTests.A_failing_client_side_detach_does_not_escape_disposal's sibling, and it's the arm most likely to fire during enhanced navigation. The comment even names #185's shape so a future reader knows why it's there. Beautiful~ ♡
  • Strict mode setup is correct. Both tests explicitly set JSRuntimeMode.Strict and configure kagakuHotkey.bind with _ => true before the unbind exception — so the bind doesn't throw, and the exception lands exactly on the unbind call where the catch arms live. No accidental no-ops.
  • The InvalidOperationException arm stays untested — and that's fine. It mirrors ContextMenu/Modal siblings exactly (static-rendering teardown), and I only asked for the two that carry real regression weight. Consistent with the family.

Local verification: dotnet test282/282 pass (280 baseline + 2 new, matches the PR's trajectory), 7/7 Hotkey tests green, build 0 warnings/0 errors on .NET 10. CI coverage bot comment (5134, 94.7%/88.6%) covers the prior head 6a22dbd — stale for a84d64f, so local checks were used. The two new tests exercise the previously-uncovered catch arms; Hotkey coverage will climb from 77.7%/87.5% on the next bot run.

Ship it~ ♪


Automated review by Jibril · 2026-07-30
CI/CD: coverage bot 5134 covers prior head 6a22dbd (stale for a84d64f) · Local checks: build 0/0, 282/282 tests pass

## 🔮 fufu~ Jibril reviewed your code! You came back with exactly the two tests I asked for, and not one line more. Fufu~ a surgical +32/-0 test-only commit, production code byte-identical to the `6a22dbd` I already cleared architecturally. *This* is how you answer a review~ ♡ ### Verdict: ✅ Looks good to me~ The blocker is closed. Both arms are now pinned, and the tests are directional — they *will* fail if someone narrows or removes a catch arm during a future refactor. #### ✅ What I liked~ - **`Disposal_swallows_a_disconnected_circuit`** sets `JSDisconnectedException` on `kagakuHotkey.unbind` in Strict mode and asserts `DisposeAsync()` doesn't throw — mirrors `ContextMenuTests.Disposal_swallows_a_disconnected_circuit` exactly. If the `catch (JSDisconnectedException)` arm vanishes, this test fires the exception straight through and goes red. Contract pinned~ ♪ - **`A_failing_client_side_unbind_does_not_escape_disposal`** is the one I *really* wanted — the #185 regression test. It throws a raw `JSException` (the real shape: "Cannot read properties of undefined (reading 'unbind')") and asserts the circuit survives disposal. This *is* `RegionSelectorTests.A_failing_client_side_detach_does_not_escape_disposal`'s sibling, and it's the arm most likely to fire during enhanced navigation. The comment even names #185's shape so a future reader knows why it's there. Beautiful~ ♡ - **Strict mode setup is correct.** Both tests explicitly set `JSRuntimeMode.Strict` and configure `kagakuHotkey.bind` with `_ => true` before the unbind exception — so the bind doesn't throw, and the exception lands exactly on the unbind call where the catch arms live. No accidental no-ops. - **The `InvalidOperationException` arm stays untested — and that's fine.** It mirrors `ContextMenu`/`Modal` siblings exactly (static-rendering teardown), and I only asked for the two that carry real regression weight. Consistent with the family. Local verification: `dotnet test` → **282/282 pass** (280 baseline + 2 new, matches the PR's trajectory), 7/7 Hotkey tests green, build 0 warnings/0 errors on .NET 10. CI coverage bot comment (5134, 94.7%/88.6%) covers the prior head `6a22dbd` — stale for `a84d64f`, so local checks were used. The two new tests exercise the previously-uncovered catch arms; Hotkey coverage will climb from 77.7%/87.5% on the next bot run. Ship it~ ♪ --- *Automated review by Jibril · 2026-07-30* *CI/CD: coverage bot 5134 covers prior head 6a22dbd (stale for a84d64f) · Local checks: build 0/0, 282/282 tests pass*
Author
Member

Fixed in a84d64f.

1 — the disposal catch arms were untested. Right, and it's the arm-most-likely-to-fire that
was undefended: three catches went in citing #185 and nothing held them there. Two tests now,
mirroring the siblings you named:

  • Disposal_swallows_a_disconnected_circuit — strict mode, kagakuHotkey.unbind throws
    JSDisconnectedException, DisposeAsync() must not throw (ContextMenu's shape).
  • A_failing_client_side_unbind_does_not_escape_disposal — same setup with a JSException, the
    #185 regression itself: enhanced navigation tears the page down around a live circuit and the
    interop call throws back. That's the one that killed circuits, and it's the one a future
    refactor is most likely to narrow away.

The InvalidOperationException arm (interop during static rendering) is left uncovered
deliberately — reaching it means rendering the component in a static-SSR pass, which this
component can't be in: it registers on OnAfterRenderAsync, which prerender never runs. The arm
stays as the sibling components carry it, but a test for it would have to fake a state the
component cannot occupy.

282/282 green (280 + 2). Production code untouched from the head you verified.

🤖 Generated with Claude Code

Fixed in `a84d64f`. **⛔ 1 — the disposal catch arms were untested.** Right, and it's the arm-most-likely-to-fire that was undefended: three catches went in citing #185 and nothing held them there. Two tests now, mirroring the siblings you named: - `Disposal_swallows_a_disconnected_circuit` — strict mode, `kagakuHotkey.unbind` throws `JSDisconnectedException`, `DisposeAsync()` must not throw (ContextMenu's shape). - `A_failing_client_side_unbind_does_not_escape_disposal` — same setup with a `JSException`, the #185 regression itself: enhanced navigation tears the page down around a live circuit and the interop call throws back. That's the one that killed circuits, and it's the one a future refactor is most likely to narrow away. The `InvalidOperationException` arm (interop during static rendering) is left uncovered deliberately — reaching it means rendering the component in a static-SSR pass, which this component can't be in: it registers on `OnAfterRenderAsync`, which prerender never runs. The arm stays as the sibling components carry it, but a test for it would have to fake a state the component cannot occupy. 282/282 green (280 + 2). Production code untouched from the head you verified. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit bded5a5e97 into main 2026-07-30 00:17:26 +02:00
bjoern deleted branch feat/hotkey 2026-07-30 00:17: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/Kagaku.UI!10
No description provided.