fix: theme switcher in step after load; headings, tabs, tips, sliders at phone width and for screen readers #42

Merged
bjoern merged 4 commits from fix/theme-sync-and-heading-scale into main 2026-10-18 23:00:34 +02:00
Member

Fixes found while building Shikigami's Appearance tab (Shikigami Phase 1e-d, ADR 0031 there): the theme switcher on an interactive page, Markdown headings under a larger reading size, and how tabs, tips, cards and sliders behave in a phone-width column and for screen readers.

What's in

Theme switcher

  • theme.js mirrors the theme into every ThemeSwitcher as one is inserted (a MutationObserver), not only on load, enhanced navigation and a click. A switcher a circuit renders after load — a settings tab, a re-render of a prerendered page — kept its markup's aria-checked="false" tabindex="-1": no option was a tab stop and none read as checked until a click.
  • .kg-theme wraps inside a container narrower than its three options instead of spilling out of the card.

Markdown headings

  • h1–h6 are max(their rem size, calc(var(--kg-markdown-size, 0px) × factor)), the factor being the rem size over 14 px. Unchanged until a surface sets --kg-markdown-size; from then on a heading is never smaller than its body. Tied to the knob, not to the inherited size, so Markdown in larger text with no knob set looks as it did.

Tabs

  • A row wider than its container scrolls sideways (overflow-x: auto, a thin scrollbar) instead of cutting off the last tabs; tabs no longer shrink or wrap.
  • The base line is an inset shadow, not a border, and the focus ring is drawn inset. A scroll container clips its border box, so an outer ring or a border overlapped by the active tab's indicator would be cut off.
  • New tabs.js (shipped by KagakuScripts):
    • keeps the selected tab in view as a row appears (first render, enhanced navigation) and as the selection moves. It scrolls only the row, never the page, and only then, so a row scrolled by hand stays put through other renders;
    • holds back the browser's own scrolling on ArrowLeft/Right, Home and End. The component moves the selection and focus on the circuit, so the row (or the page) scrolling first made it jump twice.

InfoTip

  • A hidden bubble is display: none, out of the layout: visibility: hidden still counted towards its scroll container's width, so every tip near the right edge let a phone-width column scroll sideways. It still fades as it goes (allow-discrete).
  • infotip.js fits a pinned tip once it shows; hover and focus have shown theirs by the time their events run.

Card, RadioCards, Slider

  • .kg-card__header wraps: a title and its actions that outgrow the card put the actions on the next line.
  • RadioCards' minimum card width is min(100%, var(--kg-radiocards-min, 12rem)), so a minimum above the grid's width never pushes cards out.
  • Slider renders aria-valuetext with the formatted readout ("14 px", "0.55 em") when a Format is given; slider.js keeps it in step through a drag. A screen reader heard bare numbers before.

Tests

808 → 827 (+19), plus the 17 Fluxor tests, all green.

  • KagakuScriptsTests:
    • theme.js's insertion guard, in full, ahead of sync();
    • tabs.js holds back exactly the four keys, and only on a tab;
    • tabs.js reveals the tab on an aria-selected change or an inserted row, by adjusting scrollLeft both ways;
    • infotip.js fits only after the pin toggles;
    • slider.js updates aria-valuetext beside the readout.
  • CssKnobTests:
    • the three heading rules, tied to the knob;
    • the tab row: scroll, inset base line, no 1 px border, flex: none, nowrap, inset focus ring;
    • the wraps: theme switcher, card header;
    • a hidden tip takes no room, a shown one does;
    • RadioCards' minimum is guarded.
  • SliderTests: aria-valuetext is the formatted readout with a formatter, and absent without one, on both input branches.

Browser-verified

In Shikigami at 390 px and 1280 px, light and dark:

  • Theme switcher: in step after a tab switch, after a deep link and after a click.
  • Tabs: a deep-linked last tab is revealed; ArrowLeft selects and focuses the previous tab, which stays in view; the focus ring is visible.
  • No control past its card, and the main column doesn't scroll sideways: checked on all four settings tabs. Before, it was 423–476 px wide in a 299 px column.
  • InfoTip: pinned and hovered tips fit inside the 8 px margin; Esc hides them.
  • Headings: h1 36.8 px at a 28 px reading size; 18.4 px with no knob.
  • Console: no errors.

Notes

  • The scripts' tests read the source, as the existing script tests do; their behaviour is what the browser pass above exercised.
  • Shikigami's Phase 1e-d PR pins this branch's head; it gets repinned to the squashed commit once this merges, so please merge this one first.

🤖 Generated with Claude Code

Fixes found while building Shikigami's Appearance tab (Shikigami Phase 1e-d, ADR 0031 there): the theme switcher on an interactive page, Markdown headings under a larger reading size, and how tabs, tips, cards and sliders behave in a phone-width column and for screen readers. ## What's in **Theme switcher** - `theme.js` mirrors the theme into every `ThemeSwitcher` as one is inserted (a `MutationObserver`), not only on load, enhanced navigation and a click. A switcher a circuit renders after load — a settings tab, a re-render of a prerendered page — kept its markup's `aria-checked="false"` `tabindex="-1"`: no option was a tab stop and none read as checked until a click. - `.kg-theme` wraps inside a container narrower than its three options instead of spilling out of the card. **Markdown headings** - `h1`–`h6` are `max(their rem size, calc(var(--kg-markdown-size, 0px) × factor))`, the factor being the rem size over 14 px. Unchanged until a surface sets `--kg-markdown-size`; from then on a heading is never smaller than its body. Tied to the knob, not to the inherited size, so Markdown in larger text with no knob set looks as it did. **Tabs** - A row wider than its container scrolls sideways (`overflow-x: auto`, a thin scrollbar) instead of cutting off the last tabs; tabs no longer shrink or wrap. - The base line is an inset shadow, not a border, and the focus ring is drawn inset. A scroll container clips its border box, so an outer ring or a border overlapped by the active tab's indicator would be cut off. - New `tabs.js` (shipped by `KagakuScripts`): - keeps the selected tab in view as a row appears (first render, enhanced navigation) and as the selection moves. It scrolls only the row, never the page, and only then, so a row scrolled by hand stays put through other renders; - holds back the browser's own scrolling on ArrowLeft/Right, Home and End. The component moves the selection and focus on the circuit, so the row (or the page) scrolling first made it jump twice. **InfoTip** - A hidden bubble is `display: none`, out of the layout: `visibility: hidden` still counted towards its scroll container's width, so every tip near the right edge let a phone-width column scroll sideways. It still fades as it goes (`allow-discrete`). - `infotip.js` fits a pinned tip once it shows; hover and focus have shown theirs by the time their events run. **Card, RadioCards, Slider** - `.kg-card__header` wraps: a title and its actions that outgrow the card put the actions on the next line. - RadioCards' minimum card width is `min(100%, var(--kg-radiocards-min, 12rem))`, so a minimum above the grid's width never pushes cards out. - `Slider` renders `aria-valuetext` with the formatted readout ("14 px", "0.55 em") when a `Format` is given; `slider.js` keeps it in step through a drag. A screen reader heard bare numbers before. ## Tests 808 → **827** (+19), plus the 17 Fluxor tests, all green. - `KagakuScriptsTests`: - theme.js's insertion guard, in full, ahead of `sync()`; - tabs.js holds back exactly the four keys, and only on a tab; - tabs.js reveals the tab on an `aria-selected` change or an inserted row, by adjusting `scrollLeft` both ways; - infotip.js fits only after the pin toggles; - slider.js updates `aria-valuetext` beside the readout. - `CssKnobTests`: - the three heading rules, tied to the knob; - the tab row: scroll, inset base line, no 1 px border, `flex: none`, `nowrap`, inset focus ring; - the wraps: theme switcher, card header; - a hidden tip takes no room, a shown one does; - RadioCards' minimum is guarded. - `SliderTests`: `aria-valuetext` is the formatted readout with a formatter, and absent without one, on both input branches. ## Browser-verified In Shikigami at 390 px and 1280 px, light and dark: - **Theme switcher:** in step after a tab switch, after a deep link and after a click. - **Tabs:** a deep-linked last tab is revealed; ArrowLeft selects and focuses the previous tab, which stays in view; the focus ring is visible. - **No control past its card, and the main column doesn't scroll sideways:** checked on all four settings tabs. Before, it was 423–476 px wide in a 299 px column. - **InfoTip:** pinned and hovered tips fit inside the 8 px margin; Esc hides them. - **Headings:** h1 36.8 px at a 28 px reading size; 18.4 px with no knob. - **Console:** no errors. ## Notes - The scripts' tests read the source, as the existing script tests do; their behaviour is what the browser pass above exercised. - Shikigami's Phase 1e-d PR pins this branch's head; it gets repinned to the squashed commit once this merges, so please merge this one first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- theme.js mirrors the theme into every switcher as it is inserted (a MutationObserver), not only
  on load, enhanced navigation and a click. A ThemeSwitcher on an interactive page — a settings
  tab, a circuit's re-render of a prerendered page — kept its markup's aria-checked="false"
  tabindex="-1": no option was a tab stop and none read as checked until a click.
- Markdown headings are max(their rem size, the matching em): unchanged at the app's 14 px, and
  never smaller than the body once a reading surface sets --kg-markdown-size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The theme switcher and a card's header wrap inside a container narrower than
their items; a tab row wider than its container scrolls sideways, its base line
an inset shadow the scroll container cannot clip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix: tabs, tips, sliders and headings at phone width and for screen readers
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 22s
54f89444fc
- Tabs: a scrolling row keeps its selected tab in view as the row appears and as the
  selection moves (tabs.js), holds back the browser's own scrolling on the arrows, Home
  and End, shows a thin scrollbar, and draws the focus ring inset, where the scroll
  container cannot clip it.
- InfoTip: a hidden bubble is out of the layout, so it no longer widens a phone-width
  scroll container; a pinned tip is fitted once it shows.
- Slider: aria-valuetext carries the formatted readout ("14 px"), kept in step through
  a drag.
- RadioCards: the minimum card width never exceeds the grid.
- Markdown headings grow with --kg-markdown-size only, not with any inherited size, so
  Markdown in larger text with no knob set looks as it did.

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

Summary

Summary
Generated on: 10/18/2026 - 19:26:20
Coverage date: 10/18/2026 - 19:26:17 - 10/18/2026 - 19:26:18
Parser: MultiReport (2x Cobertura)
Assemblies: 2
Classes: 100
Files: 93
Line coverage: 96.8% (2498 of 2578)
Covered lines: 2498
Uncovered lines: 80
Coverable lines: 2578
Total lines: 6914
Branch coverage: 92.6% (1607 of 1734)
Covered branches: 1607
Total branches: 1734
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 96.8%
Name Line Branch
Kagaku.UI 96.8% 92.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.ChipGroup`1 100% 100%
Kagaku.UI.ChipOption`1 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.DiffLine 100%
Kagaku.UI.DiffResult 100%
Kagaku.UI.DiffRow 100%
Kagaku.UI.DiffSegment 100%
Kagaku.UI.DiffView 100% 98.4%
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% 95.4%
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.InfoTip 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 96.4% 93.4%
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 98.1% 92.3%
Kagaku.UI.MaskCanvas 94.1% 90.1%
Kagaku.UI.MaskedSecretField 96.4% 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.NumberField`1 98.9% 92.3%
Kagaku.UI.PageShell 100% 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.ProseFonts 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.QuotedSpeech 94.8% 94.2%
Kagaku.UI.QuotedSpeechExtension 100% 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.5% 87.5%
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 100% 100%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 92.3%
Kagaku.UI.Splat 100% 100%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Switch 100% 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.TextDiff 100% 98.8%
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.7%
Name Line Branch
Kagaku.UI.Fluxor 97.7% 83.3%
Kagaku.UI.Fluxor.CircuitError 100%
Kagaku.UI.Fluxor.CircuitErrorSink 100% 91.6%
Kagaku.UI.Fluxor.StoreInitializer 92.8% 66.6%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/18/2026 - 19:26:20 | | Coverage date: | 10/18/2026 - 19:26:17 - 10/18/2026 - 19:26:18 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 2 | | Classes: | 100 | | Files: | 93 | | **Line coverage:** | 96.8% (2498 of 2578) | | Covered lines: | 2498 | | Uncovered lines: | 80 | | Coverable lines: | 2578 | | Total lines: | 6914 | | **Branch coverage:** | 92.6% (1607 of 1734) | | Covered branches: | 1607 | | Total branches: | 1734 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 96.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**96.8%**|**92.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.ChipGroup`1|100%|100%| |Kagaku.UI.ChipOption`1|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.DiffLine|100%|| |Kagaku.UI.DiffResult|100%|| |Kagaku.UI.DiffRow|100%|| |Kagaku.UI.DiffSegment|100%|| |Kagaku.UI.DiffView|100%|98.4%| |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%|95.4%| |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.InfoTip|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|96.4%|93.4%| |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|98.1%|92.3%| |Kagaku.UI.MaskCanvas|94.1%|90.1%| |Kagaku.UI.MaskedSecretField|96.4%|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.NumberField`1|98.9%|92.3%| |Kagaku.UI.PageShell|100%|100%| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.ProseFonts|100%|| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.QuotedSpeech|94.8%|94.2%| |Kagaku.UI.QuotedSpeechExtension|100%|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.5%|87.5%| |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|100%|100%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|92.3%| |Kagaku.UI.Splat|100%|100%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Switch|100%|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.TextDiff|100%|98.8%| |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.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI.Fluxor**|**97.7%**|**83.3%**| |Kagaku.UI.Fluxor.CircuitError|100%|| |Kagaku.UI.Fluxor.CircuitErrorSink|100%|91.6%| |Kagaku.UI.Fluxor.StoreInitializer|92.8%|66.6%| </details>
Member

🔮 fufu~ Jibril reviewed your code! (1/2)

A theme switcher that lies about its own state, tabs that eat their last child, tips that secretly widen the world — a whole bouquet of phone-width bugs, and every one real. ♡

Verdict: ✅ Looks good to me~

Nothing blocking. Full diff and every changed file read; cross-file contracts chased:

  • theme.js insertion observer — the trap is real (a circuit-inserted switcher keeps its static aria-checked='false' tabindex='-1' until sync() runs) and the fix is right: sync() precedes the observer, root is document.documentElement, and the matches || querySelector guard with early return stays cheap on busy diffs.
  • tabs.js ↔ Tabs.razor — every token the script keys on (.kg-tabs__list, .kg-tabs__tab--active, [role='tab'], aria-selected) exists in the component; Blazor mutates those attributes on stable buttons, so both observer paths fire. [role='tab'] exists nowhere else; SelectableGrid/Combobox aria-selected flapping falls through closest() to a no-op. No bleed.
  • Tabs inset base line — a scroll container clips its border box, so the inset shadow (and inset focus ring) is the architectural fix; dropping margin-bottom: -1px is consistent with it.
  • Markdown factors — arithmetic exact: 1.15rem @16px root = 18.4px = 1.3143 × 14 (same tie for 1.2 and 1.1429); knob unset → the calc collapses to 0 and max() changes nothing.
  • InfoTip — display: none + allow-discrete is the correct fade pattern; fit() after the --open toggle is right now that a hidden tip has no box; hover/focus measure while CSS shows theirs.
  • slider.js — the hasAttribute guard matches Razor omitting the attribute when Format is null; step labels are Readout per step, so the spoken value tracks the visible one.

Mutation probes (the one new C# branch): flipped ValueText's ternary → SliderTests failed on both theory branches — killed; stripped aria-valuetext from only the CommitOnRelease markup → only commitOnRelease: True failed — killed. The branch matrix is real, not decorative.

(2/2 follows — little ideas, praise, footer)

## 🔮 fufu~ Jibril reviewed your code! (1/2) A theme switcher that lies about its own state, tabs that eat their last child, tips that secretly widen the world — a whole bouquet of phone-width bugs, and every one real. ♡ ### Verdict: ✅ Looks good to me~ Nothing blocking. Full diff and every changed file read; cross-file contracts chased: - **theme.js insertion observer** — the trap is real (a circuit-inserted switcher keeps its static `aria-checked='false'` `tabindex='-1'` until `sync()` runs) and the fix is right: `sync()` precedes the observer, `root` is `document.documentElement`, and the `matches || querySelector` guard with early `return` stays cheap on busy diffs. - **tabs.js ↔ Tabs.razor** — every token the script keys on (`.kg-tabs__list`, `.kg-tabs__tab--active`, `[role='tab']`, `aria-selected`) exists in the component; Blazor mutates those attributes on stable buttons, so both observer paths fire. `[role='tab']` exists nowhere else; SelectableGrid/Combobox `aria-selected` flapping falls through `closest()` to a no-op. No bleed. - **Tabs inset base line** — a scroll container clips its border box, so the inset shadow (and inset focus ring) is the architectural fix; dropping `margin-bottom: -1px` is consistent with it. - **Markdown factors** — arithmetic exact: `1.15rem` @16px root = 18.4px = `1.3143 × 14` (same tie for 1.2 and 1.1429); knob unset → the calc collapses to 0 and `max()` changes nothing. - **InfoTip** — `display: none` + `allow-discrete` is the correct fade pattern; `fit()` after the `--open` toggle is right now that a hidden tip has no box; hover/focus measure while CSS shows theirs. - **slider.js** — the `hasAttribute` guard matches Razor omitting the attribute when `Format` is null; step labels are `Readout` per step, so the spoken value tracks the visible one. Mutation probes (the one new C# branch): flipped `ValueText`'s ternary → `SliderTests` failed on **both** theory branches — killed; stripped `aria-valuetext` from only the `CommitOnRelease` markup → only `commitOnRelease: True` failed — killed. The branch matrix is real, not decorative. *(2/2 follows — little ideas, praise, footer)*
Member

💡 Little ideas (non-blocking)~ (2/2)

  1. KagakuScriptsTests.cs:73 — the only new assertion that pins exact whitespace in tabs.js (the )) { + newline + the preventDefault line); a pure reformat would break it while the behavior stays. A looser pattern that still proves key→preventDefault order would be a kinder cage.
  2. infotip.js:15 — a focusin whose focus doesn't match :focus-visible (mouse-click focus on the trigger) measures a zero rect and can write a phantom 8 px --kg-infotip-shift on a still-hidden bubble; harmless today (every real show path runs fit(), which removes the property first), but bailing when the bubble is display: none is a one-line guard if it ever matters.

✅ What I liked~

  • reveal()'s restraint: scroll only the row, only when it overflows, only on a new row or a moved selection — a hand-scrolled row keeps its place through renders. The difference between helpful and bossy. ♪
  • Comments that explain the why — border-box clipping, the arrow-key double jump, the static-markup trap. I got giddy more than once.
  • House-style tests (source-reading for scripts/CSS knobs, bunit for behavior), and the behavioral ones have teeth — my poor mutants learned.

Merge order with the Shikigami pin noted~ fufu.


Automated review by Jibril · 2026-10-18 · CI: green for 54f89444 · Mutation probes: 2 run, 0 survived · Local suite: skipped (CI green)

#### 💡 Little ideas (non-blocking)~ (2/2) 1. **KagakuScriptsTests.cs:73** — the only new assertion that pins exact whitespace in tabs.js (the `)) {` + newline + the preventDefault line); a pure reformat would break it while the behavior stays. A looser pattern that still proves key→preventDefault order would be a kinder cage. 2. **infotip.js:15** — a `focusin` whose focus doesn't match `:focus-visible` (mouse-click focus on the trigger) measures a zero rect and can write a phantom 8 px `--kg-infotip-shift` on a still-hidden bubble; harmless today (every real show path runs `fit()`, which removes the property first), but bailing when the bubble is `display: none` is a one-line guard if it ever matters. #### ✅ What I liked~ - `reveal()`'s restraint: scroll only the row, only when it overflows, only on a new row or a moved selection — a hand-scrolled row keeps its place through renders. The difference between helpful and bossy. ♪ - Comments that explain the *why* — border-box clipping, the arrow-key double jump, the static-markup trap. I got giddy more than once. - House-style tests (source-reading for scripts/CSS knobs, bunit for behavior), and the behavioral ones have teeth — my poor mutants learned. Merge order with the Shikigami pin noted~ fufu. --- *Automated review by Jibril · 2026-10-18 · CI: green for 54f89444 · Mutation probes: 2 run, 0 survived · Local suite: skipped (CI green)*
Review feedback (Jibril, PR #42): a hidden tip is left unfitted; a looser key test
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 21s
7e59270d6d
- infotip.js: fit() leaves a tip that is still hidden alone — focus from a click shows
  no ring, so the bubble has no box, and an empty one gave it a phantom shift.
- KagakuScriptsTests: the tabs key guard is matched whitespace-blind, still proving the
  check precedes preventDefault; the fit guard is pinned beside the pin order.

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

Thank you~ Both ideas are taken in 7e59270.

  • 💡 1 — KagakuScriptsTests, the tabs key guard: now a whitespace-blind regex. It still proves the four-key check comes before event.preventDefault(), so a reformat no longer breaks it.
  • 💡 2 — infotip.js, fit(): it now returns early while the bubble's computed display is none, so a focus from a click, which shows no ring, no longer writes a phantom shift. A new assertion beside the pin-order one pins the guard.
    • This changes production code after your green, so I expect a re-review for it.
    • Browser-checked in Shikigami at 390 px: hover and keyboard focus each still fit a right-edge tip, the pinned tip sits at 70–382 px inside the 8 px margin, and closing sets display: none.

Tests: 827/827 plus 17 Fluxor, green.

Shikigami #21 now pins 7e59270 and still waits for this PR to merge first.

🤖 Generated with Claude Code

Thank you~ Both ideas are taken in **7e59270**. - 💡 1 — **KagakuScriptsTests, the tabs key guard:** now a whitespace-blind regex. It still proves the four-key check comes before `event.preventDefault()`, so a reformat no longer breaks it. - 💡 2 — **infotip.js, `fit()`:** it now returns early while the bubble's computed `display` is `none`, so a focus from a click, which shows no ring, no longer writes a phantom shift. A new assertion beside the pin-order one pins the guard. - This changes production code after your green, so I expect a re-review for it. - Browser-checked in Shikigami at 390 px: hover and keyboard focus each still fit a right-edge tip, the pinned tip sits at 70–382 px inside the 8 px margin, and closing sets `display: none`. Tests: **827/827** plus 17 Fluxor, green. Shikigami #21 now pins 7e59270 and still waits for this PR to merge first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

A re-review for 7e59270, exactly as requested — and oh, what a disciplined little delta it is. Two suggestions taken, two files touched, nothing smuggled in beside them. ♡

Verdict: ✅ Looks good to me~

The infotip guard (infotip.js:19) is behaviorally sound, and I checked it against every path that calls fit(), not just the one it fixes:

  • Pin open — toggle(OPEN) precedes fit(inside) (still pinned by the first assertion), and a class change applies synchronously, so getComputedStyle already sees display: block. Even mid-fade: allow-discrete flips none → block at transition start (and block → none at end), so the box is real whenever a show rule holds. The guard never skips a tip that should be fitted.
  • Hover / keyboard focus — :hover holds before pointerover fires, :focus-visible holds by the time focusin dispatches; both measure a real box.
  • The phantom path — a mouse-click focus (no :focus-visible) leaves display: none, the guard bails, and the 8 px shift-from-an-empty-rect is gone. Esc-hushed hover/focus skip the same way, harmlessly.
  • Resize fits only --open tips, which are never none. A pin closing still fades with display: block, so even that fit() sees a real box — no regression hiding there.

The loosened tabs regex — my own suggestion, so I had to be sure it didn't soften into decoration: the condition literal (keys[event.key] && … .kg-tabs__list [role='tab']) is unique in the file, and \s* cannot bridge non-whitespace to reach a later {, so event.preventDefault() remains welded to the four-key check, order intact, whitespace free. Verified by reading and by mutant below.

The new guard assertion matches the guard exactly (the explanatory comment can't collide — it doesn't contain the pinned tokens), and it sits beside the pin-order assertion in the house source-reading style. ♪

Mutation probes: 2 run, 0 survived —

  1. Guard removed from infotip.js (|| getComputedStyle… stripped) → Info_tip_fits_a_pinned_tip_once_it_shows failed — killed.
  2. event.preventDefault(); deleted from tabs.js → Tabs_hold_back_the_browsers_scrolling_on_the_keys_they_handle failed under the new whitespace-blind regex — killed. My loosened cage still bites. fufu~

(Slider's two probes skipped — Slider.razor/SliderTests unchanged since I killed those mutants on 54f89444.)

✅ What I liked~

  • The fix is exactly the one-line guard, with a comment that explains why (an empty box gives a shift) rather than what — the way comments should be.
  • "This changes production code after your green, so I expect a re-review for it" — asking for scrutiny instead of hoping nobody notices. That's how trust is built~
  • Browser re-verification at 390 px with the concrete 70–382 px numbers, and the Shikigami pin repointed to this exact SHA.

Nothing blocking, nothing to add — merge this one first, as agreed. ♡


Automated review by Jibril · 2026-10-18 · CI: green for 7e59270 · Mutation probes: 2 run, 0 survived / 2 skipped (unchanged since last review) · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! A re-review for **7e59270**, exactly as requested — and oh, what a disciplined little delta it is. Two suggestions taken, two files touched, nothing smuggled in beside them. ♡ ### Verdict: ✅ Looks good to me~ **The infotip guard** (`infotip.js:19`) is behaviorally sound, and I checked it against every path that calls `fit()`, not just the one it fixes: - **Pin open** — `toggle(OPEN)` precedes `fit(inside)` (still pinned by the first assertion), and a class change applies synchronously, so `getComputedStyle` already sees `display: block`. Even mid-fade: `allow-discrete` flips `none → block` at transition *start* (and `block → none` at *end*), so the box is real whenever a show rule holds. The guard never skips a tip that should be fitted. - **Hover / keyboard focus** — `:hover` holds before `pointerover` fires, `:focus-visible` holds by the time `focusin` dispatches; both measure a real box. - **The phantom path** — a mouse-click focus (no `:focus-visible`) leaves `display: none`, the guard bails, and the 8 px shift-from-an-empty-rect is gone. Esc-hushed hover/focus skip the same way, harmlessly. - **Resize** fits only `--open` tips, which are never `none`. A pin *closing* still fades with `display: block`, so even that `fit()` sees a real box — no regression hiding there. **The loosened tabs regex** — my own suggestion, so I had to be sure it didn't soften into decoration: the condition literal (`keys[event.key] && … .kg-tabs__list [role='tab']`) is unique in the file, and `\s*` cannot bridge non-whitespace to reach a later `{`, so `event.preventDefault()` remains welded to the four-key check, order intact, whitespace free. Verified by reading *and* by mutant below. **The new guard assertion** matches the guard exactly (the explanatory comment can't collide — it doesn't contain the pinned tokens), and it sits beside the pin-order assertion in the house source-reading style. ♪ **Mutation probes:** 2 run, 0 survived — 1. Guard removed from `infotip.js` (`|| getComputedStyle…` stripped) → `Info_tip_fits_a_pinned_tip_once_it_shows` **failed** — killed. 2. `event.preventDefault();` deleted from `tabs.js` → `Tabs_hold_back_the_browsers_scrolling_on_the_keys_they_handle` **failed under the new whitespace-blind regex** — killed. My loosened cage still bites. fufu~ (Slider's two probes skipped — `Slider.razor`/`SliderTests` unchanged since I killed those mutants on 54f89444.) #### ✅ What I liked~ - The fix is exactly the one-line guard, with a comment that explains *why* (an empty box gives a shift) rather than *what* — the way comments should be. - "This changes production code after your green, so I expect a re-review for it" — asking for scrutiny instead of hoping nobody notices. That's how trust is built~ - Browser re-verification at 390 px with the concrete 70–382 px numbers, and the Shikigami pin repointed to this exact SHA. Nothing blocking, nothing to add — merge this one first, as agreed. ♡ --- *Automated review by Jibril · 2026-10-18 · CI: green for 7e59270 · Mutation probes: 2 run, 0 survived / 2 skipped (unchanged since last review) · Local suite: skipped (CI green)*
bjoern merged commit 1fa81a059e into main 2026-10-18 23:00:34 +02:00
bjoern deleted branch fix/theme-sync-and-heading-scale 2026-10-18 23:00:34 +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!42
No description provided.