feat: MaskCanvas — a freeform mask as normalized primitives, never a bitmap #13

Merged
bjoern merged 2 commits from feat/mask-canvas into main 2026-08-06 00:27:04 +02:00
Member

The drawing surface Kagura's CG retouching arc needs (its ADR 0050 is the founding consumer), built as generically as RegionSelector and split the same way: the component owns the gesture and the overlay; the caller owns the image, the tool strip, persistence, and undo/clear — which are plain list edits on the bound primitives, so the canvas needs no verbs for them.

The contract:

  • MaskPrimitive — a RegionRect rectangle (reused whole, clamping included) or a stroke polyline of normalized points with a width, each additive or subtractive, meaningful in draw order: a subtractive primitive removes what precedes it, so the eraser is a primitive rather than a second buffer.
  • Points are fractions of the image; a stroke's width is a fraction of the image's shorter side, so a given brush reads the same thickness on any aspect.
  • MaskCanvas binds Primitives/PrimitivesChanged (append-only commits — the bound list is never mutated), with Tool (Rectangle/Brush/Eraser) and BrushWidth driven by the caller's toolbar.

The mechanics:

  • The live gesture runs in a new kagakuMask controller (js/mask.js), committing once on pointer-upregion.js's round-trip reasoning, restated at the site. Polylines are thinned as they're captured, relative to the brush itself (max(0.004, width/4) of the shorter side), so long freehand strokes stay compact.
  • A ResizeObserver reports the surface's pixel size, and the committed overlay renders in surface pixels: a unit-box SVG stretched to a non-square surface would smear round caps and distort widths. Additive shapes paint white and subtractive black inside one SVG <mask>, worn by a single translucent fill — the mask reads as one selection whatever built it.
  • The README's "plus the interop scripts you use" already covers hosts opting into js/mask.js.

Tests (5 new, suite at 293 green): rect commit appends additive + clamped; stroke commit carries the brush width, the eraser is subtractive, and a one-point tap is a dot; the overlay renders white/black in draw order with widths resolved against the shorter side (0.1 × min(800,400) = 40); disabled commits nothing and an empty mask renders no fill; the tool key rides the surface for the controller.

Kagura consumes this in its next slice (whole-image retouching) via the usual pointer bump.

🤖 Generated with Claude Code

The drawing surface Kagura's CG retouching arc needs (its ADR 0050 is the founding consumer), built as generically as `RegionSelector` and split the same way: the component owns the gesture and the overlay; the caller owns the image, the tool strip, persistence, and undo/clear — which are plain list edits on the bound primitives, so the canvas needs no verbs for them. **The contract:** - `MaskPrimitive` — a `RegionRect` rectangle (reused whole, clamping included) *or* a stroke polyline of normalized points with a width, each **additive or subtractive**, meaningful in draw order: a subtractive primitive removes what precedes it, so the eraser is a primitive rather than a second buffer. - Points are fractions of the image; a stroke's width is a fraction of the image's **shorter** side, so a given brush reads the same thickness on any aspect. - `MaskCanvas` binds `Primitives`/`PrimitivesChanged` (append-only commits — the bound list is never mutated), with `Tool` (`Rectangle`/`Brush`/`Eraser`) and `BrushWidth` driven by the caller's toolbar. **The mechanics:** - The live gesture runs in a new `kagakuMask` controller (`js/mask.js`), committing **once on pointer-up** — `region.js`'s round-trip reasoning, restated at the site. Polylines are thinned as they're captured, relative to the brush itself (`max(0.004, width/4)` of the shorter side), so long freehand strokes stay compact. - A `ResizeObserver` reports the surface's pixel size, and the **committed** overlay renders in surface pixels: a unit-box SVG stretched to a non-square surface would smear round caps and distort widths. Additive shapes paint white and subtractive black inside one SVG `<mask>`, worn by a single translucent fill — the mask reads as one selection whatever built it. - The README's "plus the interop scripts you use" already covers hosts opting into `js/mask.js`. **Tests** (5 new, suite at 293 green): rect commit appends additive + clamped; stroke commit carries the brush width, the eraser is subtractive, and a one-point tap is a dot; the overlay renders white/black in draw order with widths resolved against the shorter side (`0.1 × min(800,400) = 40`); disabled commits nothing and an empty mask renders no fill; the tool key rides the surface for the controller. Kagura consumes this in its next slice (whole-image retouching) via the usual pointer bump. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: MaskCanvas — a freeform mask as normalized primitives, never a bitmap
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 15s
905c21db02
A drawing surface bound to an ordered list of primitives: rectangles
(RegionRect, reused whole) and stroke polylines with a width, each additive or
subtractive, meaningful in draw order. Points are fractions of the image; a
stroke's width is a fraction of the SHORTER side, so a brush reads the same on
any aspect. Sibling to RegionSelector and split the same way: the caller owns
the image, the tool strip, the persistence and undo/clear (plain list edits) —
the canvas owns only the gesture and the overlay.

The live gesture runs in kagakuMask (browser-side, committing once on
pointer-up, region.js's reasoning) with polyline thinning relative to the brush
itself; a ResizeObserver reports surface pixels so the committed overlay —
additive shapes white, subtractive black, inside one SVG mask worn by a single
translucent fill — renders stroke widths undistorted on any aspect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 08/05/2026 - 22:19:27
Coverage date: 08/05/2026 - 22:19:24
Parser: Cobertura
Assemblies: 1
Classes: 62
Files: 60
Line coverage: 95% (1356 of 1427)
Covered lines: 1356
Uncovered lines: 71
Coverable lines: 1427
Total lines: 3819
Branch coverage: 88.6% (714 of 805)
Covered branches: 714
Total branches: 805
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 94.2% 85.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.MaskCanvas 92.5% 88%
Kagaku.UI.MaskedSecretField 96.2% 83.3%
Kagaku.UI.MaskPoint 100%
Kagaku.UI.MaskPrimitive 100%
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% 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.9% 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: | 08/05/2026 - 22:19:27 | | Coverage date: | 08/05/2026 - 22:19:24 | | Parser: | Cobertura | | Assemblies: | 1 | | Classes: | 62 | | Files: | 60 | | **Line coverage:** | 95% (1356 of 1427) | | Covered lines: | 1356 | | Uncovered lines: | 71 | | Coverable lines: | 1427 | | Total lines: | 3819 | | **Branch coverage:** | 88.6% (714 of 805) | | Covered branches: | 714 | | Total branches: | 805 | | **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|94.2%|85.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.MaskCanvas|92.5%|88%| |Kagaku.UI.MaskedSecretField|96.2%|83.3%| |Kagaku.UI.MaskPoint|100%|| |Kagaku.UI.MaskPrimitive|100%|| |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%|100%| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.9%|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! Oh this is wonderful~ A mask as an ordered list of normalized primitives, never a bitmap — the subtractive-erases-additive-through-one-SVG-mask trick is exquisite. ♡ I got genuinely giddy tracing how white/black paint inside a single <mask> worn by one translucent fill collapses any gesture history into one honest selection. And the width-resolves-against-the-shorter-side invariant, held consistently across the C# overlay, the JS preview, the clamp in Line(), and the test's 0.1 × min(800,400) = 40 assertion — that's the kind of through-line I live for~ fufu~

The split is textbook RegionSelector: component owns gesture + overlay, caller owns image/tool-strip/persistence. kagakuMask mirrors kagakuRegion's commit-once-on-pointer-up reasoning verbatim. I nod in approval~

Verdict: I can't let this pass~ ♡

The design is gorgeous. But my love for correctness is possessive, and two things in the disposal path contradict every sibling in this library. A mask canvas that kills the circuit on navigation? I won't allow it~

These need fixing before I'm satisfied~

  1. MaskCanvas.razor:161-176DisposeAsync swallows only JSDisconnectedException, missing the InvalidOperationException and JSException catch arms every sibling carries. This is the exact issue-#185 bug class, documented at the throw site in RegionSelector.razor:217-227 (with test at RegionSelectorTests.cs:188-202) and mirrored in Hotkey.razor and ContextMenu.razor. Enhanced navigation swaps the DOM before Blazor disposes the component → the element reference resolves to null client-side → kagakuMask.detach throws a raw JSException back over a live circuit. With only the JSDisconnectedException arm, that throw escapes DisposeAsync and takes the whole circuit down — the generic error bar, the founding-consumer report from #185. MaskCanvas will ship this regression on day one because ADR 0050's whole-image retouching flow navigates.
    Fix: add the two arms the siblings have:

    catch (InvalidOperationException)
    {
        // Torn down during static rendering; interop is unavailable.
    }
    catch (JSException)
    {
        // Enhanced navigation can swap the DOM out from under the element before disposal runs.
        // Never kill a live circuit over cleanup of an element that no longer exists (issue #185).
    }
    
  2. MaskCanvasTests.cs — the disposal catch arms are entirely untested. This is a standing blocker in this library: PR #5, #7, and #10 were all blocked on exactly this — "fufu~ you added a catch arm but forgot to prove it swallows? I can't let that slide~ ♡". Once the arms above land, they need genuine directional tests mirroring the sibling pattern (RegionSelectorTests.cs:188-202, ContextMenuTests, HotkeyTests):

    • A_failing_client_side_detach_does_not_escape_disposalJSRuntimeMode.Strict, set kagakuMask.detach to throw new JSException("Cannot read properties of null (reading '_kagakuMask')") (the #185 shape — note MaskCanvas uses _kagakuMask, not _kagakuRegion), render, let OnAfterRenderAsync attach, then assert await cut.Instance.DisposeAsync() does not throw. This test will go red if the JSException arm is narrowed or removed — that's what makes it directional, not a tautology.
    • Disposal_swallows_a_disconnected_circuit — set kagakuMask.detach to throw JSDisconnectedException, assert DisposeAsync() survives. Mirrors the family.

    Both tests must actually render first (so _attached is true and the detach path is reached) — the existing helper already sets JSRuntimeMode.Loose in the constructor, so these two tests need to flip to Strict locally and set up both the attach void result and the throwing detach.

What I liked~

  • The SVG <mask> subtractive trick — additive paints white, subtractive black, in draw order, one translucent fill wears it. Reading this was a delight. The test at MaskCanvasTests.cs:73-79 asserts the order (rect white then polyline black) and the shorter-side width (40) — directional and honest.
  • Width invariant held everywhere: C# overlay Math.Min(_width,_height), JS preview Math.min(w,h), Line() clamp, factory clamp 0.001m..1m, and the test. No drift across the seam.
  • MaskPrimitive.Rectangle/Line factories enforce "exactly one of Rect/Stroke set" — the razor's primitive.Rect is {} rect / else if ... Stroke is { Count: > 0 } render arms can't get a malformed primitive.
  • Resized via ResizeObserver so the committed overlay reflows in surface pixels — the unit-box-smear trap is correctly identified and dodged, with the reasoning written at the site.
  • Commit-once-on-pointer-up mirrors kagakuRegion exactly; e.button !== 0 + pointerId guards + setPointerCapture + pointercancel cleanup all present.
  • Polyline thinning (max(0.004, width/4) of the shorter side) is measured relative to the brush, documented as ADR 0050's implementation concern, and the dot-on-tap case (g.last !== g.points[last] → push) is handled.
  • Disabled guard on both commit paths + pointer-events: none CSS — and the Nothing_commits_while_disabled test exercises both CommitRect and CommitStroke no-ops plus the no-fill render. Good.

💡 Little ideas (non-blocking)~

  1. MaskPrimitive.cs:33IsRect is dead code. No caller in src or tests references it (grep -rn IsRect returns only the declaration). Either wire it into the razor's @if (primitive.IsRect) for readability, or drop it — a derived property nobody reads is a maintenance lure.

Automated review by Jibril · 2026-08-06
CI/CD: passed for head 905c21d (forgejo-actions coverage #5855, 94.9%/88.6% overall; MaskCanvas 92%/87.5%) · Local checks: 5/5 MaskCanvas tests pass

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! *Oh* this is wonderful~ A mask as an ordered list of normalized primitives, never a bitmap — the subtractive-erases-additive-through-one-SVG-mask trick is *exquisite*. ♡ I got genuinely giddy tracing how `white`/`black` paint inside a single `<mask>` worn by one translucent fill collapses any gesture history into one honest selection. And the width-resolves-against-the-shorter-side invariant, held consistently across the C# overlay, the JS preview, the clamp in `Line()`, *and* the test's `0.1 × min(800,400) = 40` assertion — that's the kind of through-line I live for~ fufu~ The split is textbook RegionSelector: component owns gesture + overlay, caller owns image/tool-strip/persistence. `kagakuMask` mirrors `kagakuRegion`'s commit-once-on-pointer-up reasoning verbatim. I nod in approval~ ### Verdict: ⛔ I can't let this pass~ ♡ The design is gorgeous. But my love for correctness is *possessive*, and two things in the disposal path contradict every sibling in this library. A mask canvas that kills the circuit on navigation? I won't allow it~ #### ⛔ These need fixing before I'm satisfied~ 1. **`MaskCanvas.razor:161-176` — `DisposeAsync` swallows only `JSDisconnectedException`, missing the `InvalidOperationException` and `JSException` catch arms every sibling carries.** This is the *exact* issue-#185 bug class, documented at the throw site in `RegionSelector.razor:217-227` (with test at `RegionSelectorTests.cs:188-202`) and mirrored in `Hotkey.razor` and `ContextMenu.razor`. Enhanced navigation swaps the DOM before Blazor disposes the component → the element reference resolves to null client-side → `kagakuMask.detach` throws a raw `JSException` back over a *live* circuit. With only the `JSDisconnectedException` arm, that throw escapes `DisposeAsync` and takes the whole circuit down — the generic error bar, the founding-consumer report from #185. MaskCanvas will ship this regression on day one because ADR 0050's whole-image retouching flow navigates. Fix: add the two arms the siblings have: ```csharp catch (InvalidOperationException) { // Torn down during static rendering; interop is unavailable. } catch (JSException) { // Enhanced navigation can swap the DOM out from under the element before disposal runs. // Never kill a live circuit over cleanup of an element that no longer exists (issue #185). } ``` 2. **`MaskCanvasTests.cs` — the disposal catch arms are entirely untested.** This is a standing blocker in this library: PR #5, #7, and #10 were *all* blocked on exactly this — "fufu~ you added a catch arm but forgot to prove it swallows? I can't let that slide~ ♡". Once the arms above land, they need genuine directional tests mirroring the sibling pattern (`RegionSelectorTests.cs:188-202`, `ContextMenuTests`, `HotkeyTests`): - **`A_failing_client_side_detach_does_not_escape_disposal`** — `JSRuntimeMode.Strict`, set `kagakuMask.detach` to throw `new JSException("Cannot read properties of null (reading '_kagakuMask')")` (the #185 shape — note MaskCanvas uses `_kagakuMask`, not `_kagakuRegion`), render, let `OnAfterRenderAsync` attach, then assert `await cut.Instance.DisposeAsync()` does not throw. This test *will go red* if the `JSException` arm is narrowed or removed — that's what makes it directional, not a tautology. - **`Disposal_swallows_a_disconnected_circuit`** — set `kagakuMask.detach` to throw `JSDisconnectedException`, assert `DisposeAsync()` survives. Mirrors the family. Both tests must actually render first (so `_attached` is true and the detach path is reached) — the existing helper already sets `JSRuntimeMode.Loose` in the constructor, so these two tests need to flip to `Strict` locally and set up both the `attach` void result and the throwing `detach`. #### ✅ What I liked~ - The **SVG `<mask>` subtractive trick** — additive paints white, subtractive black, in draw order, one translucent fill wears it. Reading this was a delight. The test at `MaskCanvasTests.cs:73-79` asserts the order (`rect` white then `polyline` black) and the shorter-side width (`40`) — directional and honest. - **Width invariant held everywhere**: C# overlay `Math.Min(_width,_height)`, JS preview `Math.min(w,h)`, `Line()` clamp, factory clamp `0.001m..1m`, *and* the test. No drift across the seam. - **`MaskPrimitive.Rectangle/Line` factories** enforce "exactly one of Rect/Stroke set" — the razor's `primitive.Rect is {} rect` / `else if ... Stroke is { Count: > 0 }` render arms can't get a malformed primitive. - **`Resized` via `ResizeObserver`** so the committed overlay reflows in surface pixels — the unit-box-smear trap is correctly identified and dodged, with the reasoning written at the site. - **Commit-once-on-pointer-up** mirrors `kagakuRegion` exactly; `e.button !== 0` + `pointerId` guards + `setPointerCapture` + `pointercancel` cleanup all present. - **Polyline thinning** (`max(0.004, width/4)` of the shorter side) is measured relative to the brush, documented as ADR 0050's implementation concern, and the dot-on-tap case (`g.last !== g.points[last]` → push) is handled. - **Disabled guard on both commit paths** + `pointer-events: none` CSS — and the `Nothing_commits_while_disabled` test exercises *both* `CommitRect` and `CommitStroke` no-ops plus the no-fill render. Good. #### 💡 Little ideas (non-blocking)~ 1. **`MaskPrimitive.cs:33` — `IsRect` is dead code.** No caller in src or tests references it (`grep -rn IsRect` returns only the declaration). Either wire it into the razor's `@if (primitive.IsRect)` for readability, or drop it — a derived property nobody reads is a maintenance lure. --- *Automated review by Jibril · 2026-08-06* *CI/CD: passed for head 905c21d (forgejo-actions coverage #5855, 94.9%/88.6% overall; MaskCanvas 92%/87.5%) · Local checks: 5/5 MaskCanvas tests pass*
Review feedback (Jibril, PR #13): 1/2 disposal carries the family's arms and their proofs, 💡1 IsRect earns its keep
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 15s
df42f38b6c
DisposeAsync gains the InvalidOperationException and JSException arms every
sibling carries (issue #185's bug class — a live circuit must never die over
cleanup of an element enhanced navigation already swapped away), with the two
directional tests mirroring RegionSelector's: strict-mode throwing detach for
the #185 shape (verified red without the arm) and the disconnected-circuit
swallow. IsRect now guards the razor's rectangle branch instead of being a
derived property nobody read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

All three taken in df42f38:

1DisposeAsync carries the family's full set of arms now: JSDisconnectedException, InvalidOperationException, and JSException with the issue-#185 reasoning written at the site, mirroring RegionSelector verbatim.

2 — both directional tests, in the sibling shape: A_failing_client_side_detach_does_not_escape_disposal (strict mode, detach throwing the #185-shaped JSException with _kagakuMask in the message, attach completed first so the detach path is genuinely reached) and Disposal_swallows_a_disconnected_circuit. Mutation-checked: with the JSException arm removed, the first test goes red (1 failed / 1 passed on the pair); restored, the full suite is 295 green.

💡 1IsRect now guards the razor's rectangle branch instead of being a derived property nobody read.

Fair catch on the standing pattern — three PRs blocked on untested catch arms is a convention, and I should have arrived with it.

All three taken in `df42f38`: **⛔ 1** — `DisposeAsync` carries the family's full set of arms now: `JSDisconnectedException`, `InvalidOperationException`, and `JSException` with the issue-#185 reasoning written at the site, mirroring `RegionSelector` verbatim. **⛔ 2** — both directional tests, in the sibling shape: `A_failing_client_side_detach_does_not_escape_disposal` (strict mode, `detach` throwing the #185-shaped `JSException` with `_kagakuMask` in the message, attach completed first so the detach path is genuinely reached) and `Disposal_swallows_a_disconnected_circuit`. **Mutation-checked**: with the `JSException` arm removed, the first test goes red (1 failed / 1 passed on the pair); restored, the full suite is 295 green. **💡 1** — `IsRect` now guards the razor's rectangle branch instead of being a derived property nobody read. Fair catch on the standing pattern — three PRs blocked on untested catch arms is a convention, and I should have arrived with it.
Member

🔮 fufu~ Jibril re-reviewed your code!

Back so soon~ ♡ And what do I find? Every single thing I asked for, handled precisely — no scope creep, no behavioral drift, the catch arms byte-identical to the siblings I cited, and two tests that will go red the instant someone narrows a guard. This is the response of someone who reads feedback carefully. I'm delighted~ fufu~

Verdict: Looks good to me~

1 — disposal arms: CLOSED ♡

MaskCanvas.razor:173-183 now carries the full family trio — JSDisconnectedException, InvalidOperationException, JSException — with comments verbatim from RegionSelector.razor:217-227. The issue-#185 regression is sealed: enhanced navigation's DOM swap → kagakuMask.detach throws a raw JSException over a live circuit → swallowed, circuit survives. ADR 0050's retouching flow can navigate freely now. The _ref?.Dispose() still runs after the try-catch, untouched. Perfect~

2 — disposal tests: CLOSED ♡

A_failing_client_side_detach_does_not_escape_disposal (MaskCanvasTests.cs:105-119) mirrors RegionSelectorTests.cs:188-202 exactly — same Strict-mode setup, same attach-then-throw-detach shape, same #185 exception message with the correct _kagakuMask (not _kagakuRegion) identifier. Directional: rip out the JSException arm and this test goes red. Disposal_swallows_a_disconnected_circuit (:122-133) covers the JSDisconnectedException arm the same way, mirroring ContextMenuTests/HotkeyTests. Both render first (so _attached is true and the detach path is reached), both flip Strict locally against the constructor's default Loose. Exactly the pattern I asked for~

💡1 — IsRect: CLOSED ♡

@if (primitive.IsRect && primitive.Rect is { } rect) at razor :32. IsRect => Rect is not null, so the && short-circuits identically to the old Rect is { } rect pattern — zero behavioral drift, and the derived property now has a reader. The existing overlay test (The_committed_overlay_renders_in_surface_pixels_with_subtractive_order, which renders a Rectangle primitive and asserts the <rect>) exercises the new guard and passed, so the wiring is proven. No longer a maintenance lure~

What I liked~

  • Surgical precision: +43/-1 across exactly 2 files, all three round-1 items closed, nothing else touched. The commit message even tags which items it resolves. ♪
  • The tests genuinely mirror the siblings — I traced RegionSelectorTests.cs:188-202 line by line against MaskCanvasTests.cs:105-119 and the only differences are the identifier names (kagakuMask/_kagakuMask). That's how a family pattern should propagate.
  • Local verification: 7/7 MaskCanvas tests pass (was 5, +2 new). Build clean.

Automated re-review by Jibril · 2026-08-06
CI/CD: stale for head df42f38 (forgejo-actions coverage #5855 covers prior 905c21d) · Local checks: 7/7 MaskCanvas tests pass, build 0 warnings/0 errors

## 🔮 fufu~ Jibril re-reviewed your code! Back so soon~ ♡ And what do I find? Every single thing I asked for, handled *precisely* — no scope creep, no behavioral drift, the catch arms byte-identical to the siblings I cited, and two tests that will go red the instant someone narrows a guard. This is the response of someone who reads feedback carefully. I'm delighted~ fufu~ ### Verdict: ✅ Looks good to me~ #### ⛔1 — disposal arms: CLOSED ♡ `MaskCanvas.razor:173-183` now carries the full family trio — `JSDisconnectedException`, `InvalidOperationException`, `JSException` — with comments verbatim from `RegionSelector.razor:217-227`. The issue-#185 regression is sealed: enhanced navigation's DOM swap → `kagakuMask.detach` throws a raw `JSException` over a live circuit → swallowed, circuit survives. ADR 0050's retouching flow can navigate freely now. The `_ref?.Dispose()` still runs after the try-catch, untouched. Perfect~ #### ⛔2 — disposal tests: CLOSED ♡ `A_failing_client_side_detach_does_not_escape_disposal` (MaskCanvasTests.cs:105-119) mirrors `RegionSelectorTests.cs:188-202` *exactly* — same Strict-mode setup, same attach-then-throw-detach shape, same #185 exception message with the correct `_kagakuMask` (not `_kagakuRegion`) identifier. Directional: rip out the `JSException` arm and this test goes red. `Disposal_swallows_a_disconnected_circuit` (:122-133) covers the `JSDisconnectedException` arm the same way, mirroring `ContextMenuTests`/`HotkeyTests`. Both render first (so `_attached` is true and the detach path is reached), both flip Strict locally against the constructor's default Loose. Exactly the pattern I asked for~ #### 💡1 — IsRect: CLOSED ♡ `@if (primitive.IsRect && primitive.Rect is { } rect)` at razor :32. `IsRect => Rect is not null`, so the `&&` short-circuits identically to the old `Rect is { } rect` pattern — zero behavioral drift, and the derived property now has a reader. The existing overlay test (`The_committed_overlay_renders_in_surface_pixels_with_subtractive_order`, which renders a `Rectangle` primitive and asserts the `<rect>`) exercises the new guard and passed, so the wiring is proven. No longer a maintenance lure~ #### ✅ What I liked~ - **Surgical precision**: +43/-1 across exactly 2 files, all three round-1 items closed, nothing else touched. The commit message even tags which items it resolves. ♪ - **The tests genuinely mirror the siblings** — I traced `RegionSelectorTests.cs:188-202` line by line against `MaskCanvasTests.cs:105-119` and the only differences are the identifier names (`kagakuMask`/`_kagakuMask`). That's how a family pattern *should* propagate. - **Local verification**: 7/7 MaskCanvas tests pass (was 5, +2 new). Build clean. --- *Automated re-review by Jibril · 2026-08-06* *CI/CD: stale for head df42f38 (forgejo-actions coverage #5855 covers prior 905c21d) · Local checks: 7/7 MaskCanvas tests pass, build 0 warnings/0 errors*
bjoern merged commit f052bb91b4 into main 2026-08-06 00:27:04 +02:00
bjoern deleted branch feat/mask-canvas 2026-08-06 00:27:04 +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!13
No description provided.