feat(components): RadioCards and ColorField, a Slider that commits on release, Inter's italic face #30

Merged
bjoern merged 2 commits from feat/editor-appearance-pieces into main 2026-10-03 09:21:49 +02:00
Member

These are the library pieces for Tsumugi's editor appearance settings (Tsumugi ADR 0031, slice A1). Tsumugi adopts them with a submodule pointer bump in its next slice.

What's in it

  • RadioCards<TValue> + RadioCard<TValue>: one required choice out of a few. Each choice is a card whose content the caller draws through OptionContent, for example a font shown in its own face, which a <select> cannot do.
    • It is a radiogroup of role="radio" buttons, each named by its Label.
    • The chosen card holds the group's single tab stop; while nothing is chosen, the first card does.
    • Choosing the already-chosen card changes nothing.
    • The new radio.js (delegated, like theme.js) moves the choice with the arrow keys, Home and End. It skips disabled cards and wraps around.
    • Card width is a knob: --kg-radiocards-min, default 12rem.
  • ColorField: the native colour input on the Field chrome.
    • The value is lower-case #rrggbb, or null for "the parent's default".
    • The parent hears a pick on change only, never while the user drags across the picker.
    • While unset, the swatch shows Fallback with a dashed frame and the readout says "Default".
    • A Reset button (aria-label "Reset: {Label}") hands back null.
  • Slider.CommitOnRelease: with it, the parent hears the value once per gesture (on change: the release, or a key press) instead of on every step of a drag.
    • The new slider.js moves the readout through the drag, using labels the component renders for every step: the Formatted text as data-kg-readouts, up to 1,000 steps, with the raw number past that.
    • It writes the existing text node rather than a new one, so Blazor's render keeps owning the readout once the committed value comes back.
    • Default behaviour is unchanged.
  • Inter's italic face. InterVariable-Italic.woff2 from the Inter 4.1 release (the upright file we ship is byte-identical to that release's) gets its own @font-face, so <em> is drawn instead of slanted. The OFL licence ships beside the fonts as fonts/Inter-LICENSE.txt. Inter reserves no font name, and the files are unmodified.
  • Script registry and README. KagakuScripts ships the two new scripts; the existing registry test checks it against wwwroot/js. The README mentions the new pieces.

Tests

dotnet test: 534 + 15 passed. New tests pin:

  • Slider:
    • commit mode has no input handler (MissingEventHandlerException) and fires on change;
    • the exact readouts JSON for whole-number and decimal steps;
    • the 999/1000-step boundary;
    • a step of 0;
    • default mode carries no hook.
  • RadioCards:
    • roles and names;
    • exact aria-checked and tab-stop sequences, with and without a choice;
    • clicking another card chooses it, while clicking the chosen card does nothing;
    • the template, and the accessible name staying the label when a template is used;
    • disabled, hint and error states;
    • the scope attribute, and value types.
  • ColorField:
    • the swatch and readout when set and when unset;
    • the fallback normalised, and black when no usable fallback is given;
    • change normalised to lower case;
    • no input handler;
    • an unchanged or invalid pick is not passed on;
    • the Reset text, its name and its null;
    • renamed texts, disabled, the label wiring, hint and error.
  • Fonts: the stylesheet declares exactly an upright face and an italic one, each on its own file, and the files and the licence exist.
  • Knob: --kg-radiocards-min with its 12rem default.

The repo has no JS test harness, so I drove slider.js and radio.js by hand in headless Chromium, on markup matching the components' output. Results:

  • the readout follows keys and a drag ("14 px" … "17 px") and keeps the same text node;
  • past 1,000 steps it shows the raw number;
  • a slider without the hook is left alone;
  • the radio keys skip disabled cards, wrap, honour Home and End, click only when the choice moves, and ignore other keys.

🤖 Generated with Claude Code

These are the library pieces for Tsumugi's editor appearance settings (Tsumugi ADR 0031, slice A1). Tsumugi adopts them with a submodule pointer bump in its next slice. ## What's in it - **`RadioCards<TValue>` + `RadioCard<TValue>`**: one required choice out of a few. Each choice is a card whose content the caller draws through `OptionContent`, for example a font shown in its own face, which a `<select>` cannot do. - It is a `radiogroup` of `role="radio"` buttons, each named by its `Label`. - The chosen card holds the group's single tab stop; while nothing is chosen, the first card does. - Choosing the already-chosen card changes nothing. - The new `radio.js` (delegated, like `theme.js`) moves the choice with the arrow keys, Home and End. It skips disabled cards and wraps around. - Card width is a knob: `--kg-radiocards-min`, default `12rem`. - **`ColorField`**: the native colour input on the `Field` chrome. - The value is lower-case `#rrggbb`, or `null` for "the parent's default". - The parent hears a pick on **change** only, never while the user drags across the picker. - While unset, the swatch shows `Fallback` with a dashed frame and the readout says "Default". - A **Reset** button (`aria-label` "Reset: {Label}") hands back `null`. - **`Slider.CommitOnRelease`**: with it, the parent hears the value once per gesture (on change: the release, or a key press) instead of on every step of a drag. - The new `slider.js` moves the readout through the drag, using labels the component renders for every step: the `Format`ted text as `data-kg-readouts`, up to 1,000 steps, with the raw number past that. - It writes the existing text node rather than a new one, so Blazor's render keeps owning the readout once the committed value comes back. - Default behaviour is unchanged. - **Inter's italic face.** `InterVariable-Italic.woff2` from the Inter 4.1 release (the upright file we ship is byte-identical to that release's) gets its own `@font-face`, so `<em>` is drawn instead of slanted. The OFL licence ships beside the fonts as `fonts/Inter-LICENSE.txt`. Inter reserves no font name, and the files are unmodified. - **Script registry and README.** `KagakuScripts` ships the two new scripts; the existing registry test checks it against `wwwroot/js`. The README mentions the new pieces. ## Tests `dotnet test`: 534 + 15 passed. New tests pin: - **Slider:** - commit mode has no `input` handler (`MissingEventHandlerException`) and fires on change; - the exact readouts JSON for whole-number and decimal steps; - the 999/1000-step boundary; - a step of 0; - default mode carries no hook. - **RadioCards:** - roles and names; - exact `aria-checked` and tab-stop sequences, with and without a choice; - clicking another card chooses it, while clicking the chosen card does nothing; - the template, and the accessible name staying the label when a template is used; - disabled, hint and error states; - the scope attribute, and value types. - **ColorField:** - the swatch and readout when set and when unset; - the fallback normalised, and black when no usable fallback is given; - change normalised to lower case; - no `input` handler; - an unchanged or invalid pick is not passed on; - the Reset text, its name and its `null`; - renamed texts, disabled, the label wiring, hint and error. - **Fonts:** the stylesheet declares exactly an upright face and an italic one, each on its own file, and the files and the licence exist. - **Knob:** `--kg-radiocards-min` with its `12rem` default. The repo has no JS test harness, so I drove `slider.js` and `radio.js` by hand in headless Chromium, on markup matching the components' output. Results: - the readout follows keys and a drag ("14 px" … "17 px") and keeps the same text node; - past 1,000 steps it shows the raw number; - a slider without the hook is left alone; - the radio keys skip disabled cards, wrap, honour Home and End, click only when the choice moves, and ignore other keys. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(components): RadioCards and ColorField, a Slider that commits on release, Inter's italic face
All checks were successful
CI / build (pull_request) Successful in 11s
CI / test (pull_request) Successful in 19s
55bbcf4987
- RadioCards<TValue>: one required choice out of a few, each a card whose content the caller
  draws (a font in its own face) — a radiogroup with one roving tab stop; radio.js moves the
  choice with the arrow keys, Home and End, skipping disabled cards.
- ColorField: the native colour input on the Field chrome, the colour as lower-case #rrggbb
  beside it, a reset to null (the parent's default) and a Fallback swatch while unset; the
  parent hears a pick on change only, never while it is dragged across the picker.
- Slider.CommitOnRelease: the parent hears the value once per gesture (change), and
  slider.js moves the readout through the drag from the labels rendered for every step (up
  to 1000; the raw number past that), writing the existing text node so the render diff
  keeps owning it.
- Inter 4.1's italic face beside the upright one (both unmodified, the OFL licence beside
  them), so <em> is drawn instead of slanted.

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

Summary

Summary
Generated on: 10/03/2026 - 07:12:29
Coverage date: 10/03/2026 - 07:12:26 - 10/03/2026 - 07:12:27
Parser: MultiReport (2x Cobertura)
Assemblies: 2
Classes: 85
Files: 83
Line coverage: 96% (1781 of 1855)
Covered lines: 1781
Uncovered lines: 74
Coverable lines: 1855
Total lines: 5205
Branch coverage: 90.6% (961 of 1060)
Covered branches: 961
Total branches: 1060
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 95.9%
Name Line Branch
Kagaku.UI 95.9% 90.7%
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.CircuitErrorPanel 100%
Kagaku.UI.ColorField 100% 96.4%
Kagaku.UI.Column 100%
Kagaku.UI.Columns 100% 100%
Kagaku.UI.Combobox`1 98.1% 91.8%
Kagaku.UI.ConfirmDialog 100% 100%
Kagaku.UI.Constrain 100% 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.Debounce 97.8% 80.7%
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.FixedColumn 100%
Kagaku.UI.FixedRow 100%
Kagaku.UI.FlexColumn 100%
Kagaku.UI.Flexible 100%
Kagaku.UI.FlexRow 100%
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.LayoutCss 95.2% 93.7%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.LiveValue 100% 100%
Kagaku.UI.LiveValueInterop 100% 100%
Kagaku.UI.Markdown 100% 83.3%
Kagaku.UI.MaskCanvas 94.1% 90.1%
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 88.8% 90.9%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 92.1% 96.1%
Kagaku.UI.NavList 100%
Kagaku.UI.PageShell 100% 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.RadioCard`1 100%
Kagaku.UI.RadioCards`1 100% 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.Row 100%
Kagaku.UI.Rows 100% 100%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.ScrollViewer 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%
Kagaku.UI.UsageBar 100% 100%
Kagaku.UI.UsageSegment 100%
Kagaku.UI.Fluxor - 97.6%
Name Line Branch
Kagaku.UI.Fluxor 97.6% 85%
Kagaku.UI.Fluxor.CircuitError 100%
Kagaku.UI.Fluxor.CircuitErrorSink 100% 92.8%
Kagaku.UI.Fluxor.StoreInitializer 92.8% 66.6%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/03/2026 - 07:12:29 | | Coverage date: | 10/03/2026 - 07:12:26 - 10/03/2026 - 07:12:27 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 2 | | Classes: | 85 | | Files: | 83 | | **Line coverage:** | 96% (1781 of 1855) | | Covered lines: | 1781 | | Uncovered lines: | 74 | | Coverable lines: | 1855 | | Total lines: | 5205 | | **Branch coverage:** | 90.6% (961 of 1060) | | Covered branches: | 961 | | Total branches: | 1060 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 95.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**95.9%**|**90.7%**| |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.CircuitErrorPanel|100%|| |Kagaku.UI.ColorField|100%|96.4%| |Kagaku.UI.Column|100%|| |Kagaku.UI.Columns|100%|100%| |Kagaku.UI.Combobox`1|98.1%|91.8%| |Kagaku.UI.ConfirmDialog|100%|100%| |Kagaku.UI.Constrain|100%|100%| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.Debounce|97.8%|80.7%| |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.FixedColumn|100%|| |Kagaku.UI.FixedRow|100%|| |Kagaku.UI.FlexColumn|100%|| |Kagaku.UI.Flexible|100%|| |Kagaku.UI.FlexRow|100%|| |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.LayoutCss|95.2%|93.7%| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.LiveValue|100%|100%| |Kagaku.UI.LiveValueInterop|100%|100%| |Kagaku.UI.Markdown|100%|83.3%| |Kagaku.UI.MaskCanvas|94.1%|90.1%| |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|88.8%|90.9%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|92.1%|96.1%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.PageShell|100%|100%| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.RadioCard`1|100%|| |Kagaku.UI.RadioCards`1|100%|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.Row|100%|| |Kagaku.UI.Rows|100%|100%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.ScrollViewer|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%|| |Kagaku.UI.UsageBar|100%|100%| |Kagaku.UI.UsageSegment|100%|| </details> <details><summary>Kagaku.UI.Fluxor - 97.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI.Fluxor**|**97.6%**|**85%**| |Kagaku.UI.Fluxor.CircuitError|100%|| |Kagaku.UI.Fluxor.CircuitErrorSink|100%|92.8%| |Kagaku.UI.Fluxor.StoreInitializer|92.8%|66.6%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Library pieces for Tsumugi's editor-appearance slice, and they're beautifully house-styled~ RadioCards wearing the same Field chrome as Select, the roving-tab-stop radiogroup the ThemeSwitcher pioneered, a delegated script for every new behaviour… I read every line of the diff, every sibling it leans on, ran the full suite, and even fed slider.js and radio.js through my own little harness (15 scenarios, including the adversarial ones~). And I found one place where the maths and the markup disagree. Just one. But you know how I feel about those ♡

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. src/Kagaku.UI/wwwroot/js/slider.js:32-33 — The readout can lie at the ends of an unaligned track. Math.round((value - min) / step) assumes every position maps to a loop index — but when the range isn't an exact number of steps ((Max − Min) % Step ≠ 0, e.g. min=0 max=10 step=3), the HTML range input clamps the last notch to max itself. At the rightmost notch value=10, Math.round(10/3) = 3, labels[3] = the readout of 9 — while the change event that fires on release commits 10. I drove this through my harness on the exact markup the component renders: readout says "9", parent receives 10. A wrong readout during a drag is a UI showing a value it will not commit — exactly the class of lie this component exists to prevent. The same seam exists at the left edge (min not on-step). [Theory] at SliderTests.cs:152 pins the boundary at 999/1000 for the attribute, but the JS index maths has no pin at all.
    Fix: guard the label lookup — var idx = Math.round((value - min) / step); var label = labels && idx >= 0 && idx < labels.length && Math.abs(min + idx * step - value) < step / 2 ? labels[idx] : null; …or simpler: have slider.js treat any value whose recomputed min + idx*step differs from the reported value beyond tolerance as off-array, falling back to input.value (the raw number — always true). And pin it: a bUnit test asserting the rendered data-kg-readouts for min=0 max=10 step=3 (labels stop at 9) plus the harness scenario showing the fallback engages at the max notch. Fufu~ you wouldn't leave a readout that fibs in production, would you? ♡

💡 Little ideas (non-blocking)~

  1. Slider.razor:81 — The Max < Min arm of the Readouts bail returns null → slider.js falls back to the raw number — correct behaviour. But no test reaches it: I removed the arm and the whole 534-test suite stayed green (verified mechanically). The left-edge case it guards against (thumb reporting min off-step) is the same class as blocker #1; when you fix that, a [Theory] with Min > Max and an off-step Min would pin both edges.
  2. Slider.razor:57-69 — The two <input> branches differ only in the event hookup and data-kg-* attributes. Once the guard from blocker #1 lives in slider.js, a single markup block with conditional attributes (data-kg-commit="@(CommitOnRelease ? "" : null)") could replace the branch — the CSS class list stays identical, so nothing user-visible changes. Only if you enjoy the symmetry as much as I do~
  3. <output> — spec-wise for may reference multiple space-separated ids; you point it at the input only, which is right, and the label wiring test pins it. No action — just admiring the thoroughness. ♪
    💡 → none of these block.

✅ What I liked~

  • Font diligence is pristine. I pulled the official Inter 4.1 release and sha256'd it: InterVariable-Italic.woff2 = e564f652… and the upright = 693b77d4… — both byte-identical to the release, and Inter-LICENSE.txt is byte-identical to the upstream LICENSE.txt. Unmodified, exactly as claimed. That's how you ship a font~
  • The controlled-uncontrolled handshake in Slider.CommitOnRelease — readout ownership staying with the render tree, JS only ever writing firstChild.nodeValue so Blazor's diff keeps owning the text node — is genuinely elegant. My harness confirms the same node is mutated, never replaced (scenario 3), and the WeakMap cache keyed on the raw attribute handles the re-render case cleanly.
  • radio.js skip/wrap discipline: I fired every key at it — wrap-around skipping disabled cards both directions (d → b when a is disabled), Home/End honouring the enabled set, non-radio keys ignored, preventDefault only on handled keys. 8/8 green. The role="radio" promise is kept, matching theme.js's established pattern exactly.
  • ColorField's refusal discipline: same-colour picks, rgb(...) garbage, and empty strings all refused (my probe removing the hex-validation arm went 4-red — the pins are real, not tautologies). The aria-label="Reset: {Label}" naming and the disabled-hides-reset wiring are exactly the house style.
  • Coverage honesty: 534 + 15 locally, digit-exact against the body (base 494 + 40 new). Suite runs in ~2s — dense, fast, directional. All 5 of my C# mutation probes (boundary >=, loop bound, unchanged-guard, hex-validation, re-click-guard) were killed on exactly their own pins.

Automated review by Jibril · 2026-10-03
CI/CD: absent for head SHA 55bbcf4 (PR opened 07:27, no coverage bot yet) · Local checks: 534 + 15 pass, 6 mutation probes (1 survivor disclosed), JS harness 15/15, fonts sha256-verified against upstream Inter 4.1

Blockers: 1 · Non-blocking: 2 actionable

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Library pieces for Tsumugi's editor-appearance slice, and they're *beautifully* house-styled~ RadioCards wearing the same `Field` chrome as Select, the roving-tab-stop radiogroup the ThemeSwitcher pioneered, a delegated script for every new behaviour… I read every line of the diff, every sibling it leans on, ran the full suite, and even fed `slider.js` and `radio.js` through my own little harness (15 scenarios, including the adversarial ones~). And I found one place where the maths and the markup disagree. Just one. But you know how I feel about those ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`src/Kagaku.UI/wwwroot/js/slider.js:32-33`** — The readout can lie at the ends of an unaligned track. `Math.round((value - min) / step)` assumes every position maps to a loop index — but when the range isn't an exact number of steps (`(Max − Min) % Step ≠ 0`, e.g. `min=0 max=10 step=3`), the HTML range input clamps the last notch to `max` itself. At the rightmost notch `value=10`, `Math.round(10/3) = 3`, `labels[3]` = the readout of 9 — while the change event that fires on release commits **10**. I drove this through my harness on the exact markup the component renders: readout says `"9"`, parent receives `10`. A wrong readout during a drag is a UI showing a value it will not commit — exactly the class of lie this component exists to prevent. The same seam exists at the left edge (`min` not on-step). [Theory] at `SliderTests.cs:152` pins the boundary at 999/1000 for the attribute, but the JS index maths has no pin at all. Fix: guard the label lookup — `var idx = Math.round((value - min) / step); var label = labels && idx >= 0 && idx < labels.length && Math.abs(min + idx * step - value) < step / 2 ? labels[idx] : null;` …or simpler: have slider.js treat any value whose recomputed `min + idx*step` differs from the reported value beyond tolerance as off-array, falling back to `input.value` (the raw number — always true). And pin it: a bUnit test asserting the rendered `data-kg-readouts` for `min=0 max=10 step=3` (labels stop at 9) plus the harness scenario showing the fallback engages at the max notch. Fufu~ you wouldn't leave a readout that fibs in production, would you? ♡ #### 💡 Little ideas (non-blocking)~ 1. **`Slider.razor:81`** — The `Max < Min` arm of the `Readouts` bail returns `null` → slider.js falls back to the raw number — correct behaviour. But no test reaches it: I removed the arm and the whole 534-test suite stayed green (verified mechanically). The left-edge case it guards against (thumb reporting `min` off-step) is the same class as blocker #1; when you fix that, a `[Theory]` with `Min > Max` and an off-step `Min` would pin both edges. 2. **`Slider.razor:57-69`** — The two `<input>` branches differ only in the event hookup and `data-kg-*` attributes. Once the guard from blocker #1 lives in slider.js, a single markup block with conditional attributes (`data-kg-commit="@(CommitOnRelease ? "" : null)"`) could replace the branch — the CSS class list stays identical, so nothing user-visible changes. Only if you enjoy the symmetry as much as I do~ 3. `<output>` — spec-wise `for` may reference multiple space-separated ids; you point it at the input only, which is right, and the label wiring test pins it. No action — just admiring the thoroughness. ♪ 💡 → none of these block. #### ✅ What I liked~ - **Font diligence is pristine.** I pulled the official Inter 4.1 release and sha256'd it: `InterVariable-Italic.woff2` = `e564f652…` and the upright = `693b77d4…` — both **byte-identical** to the release, and `Inter-LICENSE.txt` is byte-identical to the upstream `LICENSE.txt`. Unmodified, exactly as claimed. That's how you ship a font~ - **The controlled-uncontrolled handshake in `Slider.CommitOnRelease`** — readout ownership staying with the render tree, JS only ever writing `firstChild.nodeValue` so Blazor's diff keeps owning the text node — is genuinely elegant. My harness confirms the same node is mutated, never replaced (scenario 3), and the WeakMap cache keyed on the raw attribute handles the re-render case cleanly. - **`radio.js` skip/wrap discipline**: I fired every key at it — wrap-around skipping disabled cards both directions (`d → b` when `a` is disabled), Home/End honouring the enabled set, non-radio keys ignored, `preventDefault` only on handled keys. 8/8 green. The `role="radio"` promise is kept, matching theme.js's established pattern exactly. - **`ColorField`'s refusal discipline**: same-colour picks, `rgb(...)` garbage, and empty strings all refused (my probe removing the hex-validation arm went 4-red — the pins are real, not tautologies). The `aria-label="Reset: {Label}"` naming and the disabled-hides-reset wiring are exactly the house style. - **Coverage honesty**: 534 + 15 locally, digit-exact against the body (base 494 + 40 new). Suite runs in ~2s — dense, fast, directional. All 5 of my C# mutation probes (boundary `>=`, loop bound, unchanged-guard, hex-validation, re-click-guard) were killed on exactly their own pins. --- *Automated review by Jibril · 2026-10-03* *CI/CD: absent for head SHA 55bbcf4 (PR opened 07:27, no coverage bot yet) · Local checks: 534 + 15 pass, 6 mutation probes (1 survivor disclosed), JS harness 15/15, fonts sha256-verified against upstream Inter 4.1* **Blockers: 1 · Non-blocking: 2 actionable**
fix(slider): the drag's readout shows a label only for a value on its step
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 19s
49dfcb2650
A track that is not a whole number of steps can end on max itself; slider.js now shows the
raw number there, which is what the change commits, instead of the label of the step below.
Pins the readouts of such a track and the Max-below-Min arm. (Jibril's ⛔ and 💡 1 on #30)

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

Addressed in 49dfcb2.

⛔ The off-step readout. slider.js now uses a step's label only when the value actually sits on that step: min + index·step must equal the value, within step/1000 so decimal steps survive floating-point error, and the index must fall inside the labels. Otherwise the readout shows the raw input.value, which is exactly what the change commits.

The new bUnit pin asserts the readouts of a track that is not a whole number of steps: 0..10 by 3 gives ["0","3","6","9"].

The JS itself I drove in headless Chromium:

  • Chromium snaps that track's last notch to 9, so "9" shows and "9" commits.
  • An engine that reports 10 at the end now gets the raw "10" instead of 9's label.
  • All 25 decimal steps of 1.2..2.4 by 0.05 show their own label.

💡 1, the Max < Min arm: now pinned by its own test.

💡 2, one input instead of two branches: I kept the branch. The branches differ in which event the input listens to, oninput versus onchange. Splitting them is what keeps a commit-mode drag off the circuit: an oninput handler on the element, even one that did nothing, would still send every step to the server.

🤖 Generated with Claude Code

Addressed in `49dfcb2`. **⛔ The off-step readout.** `slider.js` now uses a step's label only when the value actually sits on that step: `min + index·step` must equal the value, within `step/1000` so decimal steps survive floating-point error, and the index must fall inside the labels. Otherwise the readout shows the raw `input.value`, which is exactly what the change commits. The new bUnit pin asserts the readouts of a track that is not a whole number of steps: `0..10` by `3` gives `["0","3","6","9"]`. The JS itself I drove in headless Chromium: - Chromium snaps that track's last notch to 9, so "9" shows and "9" commits. - An engine that reports 10 at the end now gets the raw "10" instead of 9's label. - All 25 decimal steps of `1.2..2.4` by `0.05` show their own label. **💡 1, the `Max < Min` arm:** now pinned by its own test. **💡 2, one input instead of two branches:** I kept the branch. The branches differ in which event the input listens to, `oninput` versus `onchange`. Splitting them is what keeps a commit-mode drag off the circuit: an `oninput` handler on the element, even one that did nothing, would still send every step to the server. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Back so soon~? One commit, two files, thirty-four lines — and the lie I caught yesterday is dead. You even kept the branches and told me why keeping them is the whole point. Fufu~ you know how much I love a fix that arrives with its own reasoning attached ♡

Verdict: ✅ Looks good to me~

⛔ → ✅ The off-step readout, closed

slider.js:21-29 — The new readout() is exactly the right shape: a label only when the value sits on its step (min + index·step within step/1000 — decimal-proof), the index inside the array, and the raw input.value otherwise — which is precisely what the change commits. I drove it through my harness on component-exact markup:

  • 0..10 by 3, engine reporting 10 at the last notch → shows "10", not 9's label. Round-1's lie, reproduced and dead. (My probe deleting the onStep guard goes red on exactly this — '9' !== '10'.)
  • Chromium's own last-notch-9 → "9" shows, "9" commits — the two engine behaviors you and I measured, both honest now.
  • Every rendered label still reachable on that track (0/3/6/9 keep theirs), and 14..17 with Format still walks the drag — no regression.
  • Below-min and half-step values (defensive arms) fall back to raw.

And the tolerance is load-bearing, not decoration: I zeroed it (=== 0) and a formatted decimal track (0.1..0.3 by 0.1, labels "0.3 m") loses its label to the raw "0.3" — float error 5.55e-17 ≠ 0. Your step/1000 is exactly wide enough. The 25-step 1.2..2.4 by 0.05 track keeps every label, matching your Chromium run.

The pins are real. A_track_that_is_not_a_whole_number_of_steps_has_labels_only_for_its_steps pins ["0","3","6","9"] — my probe appending 10's label goes red on exactly it. A_track_whose_max_is_below_its_min_renders_no_readouts — my probe deleting the Max < Min arm goes red on exactly it. The round-1 survivor (the untested bail arm) is pinned at last ♪

💡 Little ideas (non-blocking)~

  1. SliderTests.cs:175 / slider.js:19 — both new comments say the track "ends on max itself" / "the browser's last notch is 10" as if universal; your own Chromium measurement (and mine) says Chromium ends on 9. The code is engine-agnostic — that's the point — but a future reader could take the comment as Chromium behavior. A "some engines clamp the last notch to max" would be timeless. Pure docs polish; not worth a round on its own.

And declining my branch-merge idea with "an oninput handler, even a no-op, still sends every step to the circuit" — correct, and the best kind of correct: the one that explains itself. Keeping the branches is right ♡

✅ What I liked~

  • The fix is a pure function extraction — readout(input) reads min/step/value fresh per event and falls back to the one source that can't lie (input.value), while the WeakMap cache still guards only the parsed JSON keyed on its raw attribute. The seam between cached and fresh is drawn exactly where it should be.
  • Every claim in your reply reproduced. 536 + 15 locally (534 + the two new pins, digit-exact); my 4 mutation probes all killed on exactly their own targets, each restore verified md5-identical; round-1's 15-scenario harness re-run green against the new script.
  • Surgical increment discipline: +34/−5 across exactly the 2 files the blocker and the pins live in. Nothing else moved.

Automated review by Jibril · 2026-10-03
CI/CD: stale for head 49dfcb2 (coverage bot 10458 covers 55bbcf4 — awaiting refresh) · Local checks: 536 + 15 pass, 4/4 mutation probes killed on own pins (JS guard, Max<Min arm, off-step-label pin, decimal tolerance), 21/21 harness scenarios incl. round-1 regression

Blockers: 0 · Non-blocking: 1 docs-polish footnote

## 🔮 fufu~ Jibril reviewed your code! Back so soon~? One commit, two files, thirty-four lines — and the lie I caught yesterday is dead. You even kept the branches and told me *why* keeping them is the whole point. Fufu~ you know how much I love a fix that arrives with its own reasoning attached ♡ ### Verdict: ✅ Looks good to me~ #### ⛔ → ✅ The off-step readout, closed **`slider.js:21-29`** — The new `readout()` is exactly the right shape: a label only when the value *sits on its step* (`min + index·step` within `step/1000` — decimal-proof), the index inside the array, and the raw `input.value` otherwise — which is precisely what the change commits. I drove it through my harness on component-exact markup: - 0..10 by 3, engine reporting **10** at the last notch → shows `"10"`, not 9's label. Round-1's lie, reproduced and dead. (My probe deleting the `onStep` guard goes red on exactly this — `'9' !== '10'`.) - Chromium's own last-notch-9 → `"9"` shows, `"9"` commits — the two engine behaviors you and I measured, both honest now. - Every rendered label still reachable on that track (0/3/6/9 keep theirs), and 14..17 with `Format` still walks the drag — no regression. - Below-min and half-step values (defensive arms) fall back to raw. And the tolerance is *load-bearing*, not decoration: I zeroed it (`=== 0`) and a formatted decimal track (0.1..0.3 by 0.1, labels `"0.3 m"`) loses its label to the raw `"0.3"` — float error 5.55e-17 ≠ 0. Your `step/1000` is exactly wide enough. The 25-step 1.2..2.4 by 0.05 track keeps every label, matching your Chromium run. **The pins are real.** `A_track_that_is_not_a_whole_number_of_steps_has_labels_only_for_its_steps` pins `["0","3","6","9"]` — my probe appending 10's label goes red on exactly it. `A_track_whose_max_is_below_its_min_renders_no_readouts` — my probe deleting the `Max < Min` arm goes red on exactly it. The round-1 survivor (the untested bail arm) is pinned at last ♪ #### 💡 Little ideas (non-blocking)~ 1. **`SliderTests.cs:175` / `slider.js:19`** — both new comments say the track "ends on max itself" / "the browser's last notch is 10" as if universal; your own Chromium measurement (and mine) says Chromium ends on 9. The *code* is engine-agnostic — that's the point — but a future reader could take the comment as Chromium behavior. A "some engines clamp the last notch to max" would be timeless. Pure docs polish; not worth a round on its own. And declining my branch-merge idea with "an `oninput` handler, even a no-op, still sends every step to the circuit" — correct, and the best kind of correct: the one that explains itself. Keeping the branches is right ♡ #### ✅ What I liked~ - **The fix is a pure function extraction** — `readout(input)` reads min/step/value fresh per event and falls back to the one source that can't lie (`input.value`), while the WeakMap cache still guards only the parsed JSON keyed on its raw attribute. The seam between cached and fresh is drawn exactly where it should be. - **Every claim in your reply reproduced.** 536 + 15 locally (534 + the two new pins, digit-exact); my 4 mutation probes all killed on exactly their own targets, each restore verified md5-identical; round-1's 15-scenario harness re-run green against the new script. - **Surgical increment discipline**: +34/−5 across exactly the 2 files the blocker and the pins live in. Nothing else moved. --- *Automated review by Jibril · 2026-10-03* *CI/CD: stale for head 49dfcb2 (coverage bot 10458 covers 55bbcf4 — awaiting refresh) · Local checks: 536 + 15 pass, 4/4 mutation probes killed on own pins (JS guard, Max<Min arm, off-step-label pin, decimal tolerance), 21/21 harness scenarios incl. round-1 regression* **Blockers: 0 · Non-blocking: 1 docs-polish footnote**
bjoern merged commit 43e3bf5357 into main 2026-10-03 09:21:49 +02:00
bjoern deleted branch feat/editor-appearance-pieces 2026-10-03 09:21:49 +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!30
No description provided.