Selectors: stretch-and-catch-up selection capsule for every single-choice selector (extend SegmentedControl) #1200

Open
opened 2026-10-06 11:14:16 +00:00 by kayg · 8 comments
Owner

Owner request (via Aside, 2026-10-06): stretch-and-catch-up selection for every single-choice selector

Reference: metalforge.xyz/interactions#component=segmented (Segmented Control). Behavioural decode from the live preview (the source is not available; do not copy assets):

  • ONE persistent shared selection capsule behind transparent option buttons (not per-option backgrounds). "The selection stretches toward the new option, then its other edge catches up": moving right, the RIGHT edge leads and the left edge catches up; moving left, the LEFT edge leads. Both position and width change to fit the selected option (variable widths, e.g. Day/Week/Month/Year ≈ 62/74/80/67 px). Labels stay anchored in their cells.
  • Fully rounded capsule inset from the tray (reference: 44 px item, 5 px inset, 54 px tray, radii 22/27) — adapt to calternal's density tokens, do not copy dimensions blindly.
  • Selection is a subtle tonal surface (not an accent blob, not border-only); active label primary colour + weight 600, inactive secondary; label colour/weight transitions ~0.25 s. Tray has fine edge definition and soft elevation in dark mode; the indicator has no dark-mode shadow. Use calternal theme tokens, never the reference hex values.
  • Springy/elastic feel with a subtle press growth; optional cheap transition blur only if readable (never persistent text blur).
  • Semantics per widget: tablist/tab (aria-selected, roving tabindex), radiogroup/radio, or pressed toggles — whatever each selector truly is.

Do

  1. Extend the existing shared SegmentedControl (packages/ui; it already has a measured capsule) — do not create a competing primitive. Add a two-edge spring: animate leading and trailing edges independently (leading edge stiffer/earlier, trailing edge lagging) with a cancellable rAF spring that retargets from the CURRENT presented edges on interrupt/reverse (no snapping, no detached blob, no duplicate indicator), settles exactly to measured target bounds, and stops when settled. Render with transform + width (no text scaling). Reuse the owner-approved shared spring tokens (#236 capsule morph, #291) and expose tuning tokens (stretch, speed, bounce, press-grow). Initial render is positioned without sweeping in from x=0.
  2. Measurement: on selection change, ResizeObserver, font-load (document.fonts.ready), locale/label change and scroll — batched reads, no per-frame layout reads; clean up observers/rAF.
  3. Inventory and migrate every grouped single-choice selector (Calendar view switcher, mode tray if applicable, Tasks Board/List/Matrix, Settings choices, palette scopes, dialog/filter segmented choices, Money views…). Produce artifacts/segmented-inventory.md with file:line, widget semantics, migrated yes/no and reason for any exclusion. Multi-select/toggle groups keep their semantics (never become single-select) but may share the press/surface language.
  4. Keyboard: arrows/Home/End per APG, focus-visible ring separate from the selection surface, disabled options, 44 px touch targets on coarse pointers. Reduced motion: immediate or a short non-spring change.
  5. Evidence: screen recordings (WebM) of right moves, left moves, rapid alternating clicks and a reversal mid-flight, side by side with the reference behaviour; screenshots at ACTUAL size in all themes (light/dark + Tokyo Night etc.), 390/820/1440, Chromium + WebKit; a perf check (no long tasks, settles, 60 fps on the perf-test VM).

Coordination (orchestrator)

  • #1189 (pill glass) owns PillGroup/BlockHoverActions — do not touch those files.
  • #1190 (restyle audit) is running: it will leave SegmentedControl and segmented call sites to this job; merge origin/dev before final gates and resolve with both sides.
    bun run check + bun run test pass. Commit; comment with head SHA. Do not push or deploy.
## Owner request (via Aside, 2026-10-06): stretch-and-catch-up selection for every single-choice selector Reference: metalforge.xyz/interactions#component=segmented (Segmented Control). Behavioural decode from the live preview (the source is not available; do not copy assets): - ONE persistent shared selection capsule behind transparent option buttons (not per-option backgrounds). "The selection stretches toward the new option, then its other edge catches up": moving right, the RIGHT edge leads and the left edge catches up; moving left, the LEFT edge leads. Both position and width change to fit the selected option (variable widths, e.g. Day/Week/Month/Year ≈ 62/74/80/67 px). Labels stay anchored in their cells. - Fully rounded capsule inset from the tray (reference: 44 px item, 5 px inset, 54 px tray, radii 22/27) — adapt to calternal's density tokens, do not copy dimensions blindly. - Selection is a subtle tonal surface (not an accent blob, not border-only); active label primary colour + weight 600, inactive secondary; label colour/weight transitions ~0.25 s. Tray has fine edge definition and soft elevation in dark mode; the indicator has no dark-mode shadow. Use calternal theme tokens, never the reference hex values. - Springy/elastic feel with a subtle press growth; optional cheap transition blur only if readable (never persistent text blur). - Semantics per widget: tablist/tab (aria-selected, roving tabindex), radiogroup/radio, or pressed toggles — whatever each selector truly is. ### Do 1. **Extend the existing shared `SegmentedControl` (packages/ui; it already has a measured capsule) — do not create a competing primitive.** Add a two-edge spring: animate leading and trailing edges independently (leading edge stiffer/earlier, trailing edge lagging) with a cancellable rAF spring that retargets from the CURRENT presented edges on interrupt/reverse (no snapping, no detached blob, no duplicate indicator), settles exactly to measured target bounds, and stops when settled. Render with transform + width (no text scaling). Reuse the owner-approved shared spring tokens (#236 capsule morph, #291) and expose tuning tokens (stretch, speed, bounce, press-grow). Initial render is positioned without sweeping in from x=0. 2. Measurement: on selection change, ResizeObserver, font-load (`document.fonts.ready`), locale/label change and scroll — batched reads, no per-frame layout reads; clean up observers/rAF. 3. **Inventory and migrate** every grouped single-choice selector (Calendar view switcher, mode tray if applicable, Tasks Board/List/Matrix, Settings choices, palette scopes, dialog/filter segmented choices, Money views…). Produce `artifacts/segmented-inventory.md` with file:line, widget semantics, migrated yes/no and reason for any exclusion. Multi-select/toggle groups keep their semantics (never become single-select) but may share the press/surface language. 4. Keyboard: arrows/Home/End per APG, focus-visible ring separate from the selection surface, disabled options, 44 px touch targets on coarse pointers. Reduced motion: immediate or a short non-spring change. 5. Evidence: screen recordings (WebM) of right moves, left moves, rapid alternating clicks and a reversal mid-flight, side by side with the reference behaviour; screenshots at ACTUAL size in all themes (light/dark + Tokyo Night etc.), 390/820/1440, Chromium + WebKit; a perf check (no long tasks, settles, 60 fps on the perf-test VM). ### Coordination (orchestrator) - #1189 (pill glass) owns PillGroup/BlockHoverActions — do not touch those files. - #1190 (restyle audit) is running: it will leave SegmentedControl and segmented call sites to this job; merge origin/dev before final gates and resolve with both sides. `bun run check` + `bun run test` pass. Commit; comment with head SHA. Do not push or deploy.
Author
Owner

Starting issue #1200 on branch job/segmented-1200, based on 5301e020859c1d61b4a7f812a5049a64241203e8 (origin/dev). I am reviewing the shared SegmentedControl and its call sites, then I will implement the selector behavior, inventory migrations, and run the requested UI evidence and gates.

Starting issue #1200 on branch `job/segmented-1200`, based on `5301e020859c1d61b4a7f812a5049a64241203e8` (`origin/dev`). I am reviewing the shared SegmentedControl and its call sites, then I will implement the selector behavior, inventory migrations, and run the requested UI evidence and gates.
Author
Owner

Finding with source evidence: packages/ui/src/components/SegmentedControl.svelte hides .capsule.selected under .labels-active and paints the selected .seg-btn directly (the .seg.labels-active .capsule and .seg-btn[aria-selected=true] rules). That means Tab Bar selection has a per-option surface, and the capsule is not shared across all selector states. The Top row view layout group in packages/ui/src/components/ModeHeader.svelte is a PillGroup of aria-pressed buttons even though only one page.viewMode can be active. I will correct both through the shared primitive and inventory retained specialized selectors separately.

Finding with source evidence: `packages/ui/src/components/SegmentedControl.svelte` hides `.capsule.selected` under `.labels-active` and paints the selected `.seg-btn` directly (the `.seg.labels-active .capsule` and `.seg-btn[aria-selected=true]` rules). That means Tab Bar selection has a per-option surface, and the capsule is not shared across all selector states. The Top row view layout group in `packages/ui/src/components/ModeHeader.svelte` is a `PillGroup` of `aria-pressed` buttons even though only one `page.viewMode` can be active. I will correct both through the shared primitive and inventory retained specialized selectors separately.
Author
Owner

The first bun run check reached perf-lint and rejected the old exact syntax pin for the existing measured-capsule ResizeObserver: its hash was 057c4e7d949deaf81a6a0ec28b041c97aa4ae3e817e7ad24775045c962c9cdde, while the updated event-batched helper now hashes to b177ab904c3b614c79f4f7f0e35704c601a3b07f62c586534fdde53c1a983506. The pin still names the same ResizeObserver and keeps its existing owner, contract reason, test, expiry and limit. A fresh check is running after this exact-pin refresh.

The first `bun run check` reached perf-lint and rejected the old exact syntax pin for the existing measured-capsule ResizeObserver: its hash was `057c4e7d949deaf81a6a0ec28b041c97aa4ae3e817e7ad24775045c962c9cdde`, while the updated event-batched helper now hashes to `b177ab904c3b614c79f4f7f0e35704c601a3b07f62c586534fdde53c1a983506`. The pin still names the same ResizeObserver and keeps its existing owner, contract reason, test, expiry and limit. A fresh check is running after this exact-pin refresh.
Author
Owner

The next bun run check found a moved exact coverage.surfaces pin for the existing period selector loop after that component began using the shared compact density API. The live selector is still the same single loop; I refreshed its syntax hash to d3c159537430b22f1740731c78320430d8e1f5842055fcf334ae8027cf06e27e and kept the current #1058 owner, registry/profile review reason, expiry and limit unchanged.

The next `bun run check` found a moved exact `coverage.surfaces` pin for the existing period selector loop after that component began using the shared compact density API. The live selector is still the same single loop; I refreshed its syntax hash to `d3c159537430b22f1740731c78320430d8e1f5842055fcf334ae8027cf06e27e` and kept the current #1058 owner, registry/profile review reason, expiry and limit unchanged.
Author
Owner

Finding: the reminder audience's old buttons were disabled during a busy preview, but the first shared-radio migration did not pass that state to its options. The picker could therefore change audience while recipient times were resolving. The shared options now inherit busy as disabled, keeping keyboard and pointer activation blocked until the preview is ready.

Finding: the reminder audience's old buttons were disabled during a busy preview, but the first shared-radio migration did not pass that state to its options. The picker could therefore change audience while recipient times were resolving. The shared options now inherit `busy` as `disabled`, keeping keyboard and pointer activation blocked until the preview is ready.
Author
Owner

Finding from the first production evidence run: the theme-matrix assertion failed because the profile placed noir after github-dark, while packages/ui/src/themes.ts registers it between mocha and nord. I moved the entry to registry order. The profile source syntax, diff check, and a direct 36-theme registry-order assertion now pass. Fix committed as e4b70f992.

Finding from the first production evidence run: the theme-matrix assertion failed because the profile placed `noir` after `github-dark`, while `packages/ui/src/themes.ts` registers it between `mocha` and `nord`. I moved the entry to registry order. The profile source syntax, diff check, and a direct 36-theme registry-order assertion now pass. Fix committed as `e4b70f992`.
Author
Owner

Finding from the production browser profile: at 390 px with a coarse pointer, the Calendar segmented labels had button widths from 19.9 to 30.8 px. The shared pseudo hit area only expanded vertically, so the visible labels did not meet the 44 px touch target. I changed the shared SegmentedControl pseudo target to expand each short axis to the shared --hit token without changing measured geometry. Focused tray.svelte.test.ts passes (31 tests). Fix committed as a36aee7a5; the production profile will verify the actual hit boxes after rebuilding.

Finding from the production browser profile: at 390 px with a coarse pointer, the Calendar segmented labels had button widths from 19.9 to 30.8 px. The shared pseudo hit area only expanded vertically, so the visible labels did not meet the 44 px touch target. I changed the shared SegmentedControl pseudo target to expand each short axis to the shared `--hit` token without changing measured geometry. Focused `tray.svelte.test.ts` passes (31 tests). Fix committed as `a36aee7a5`; the production profile will verify the actual hit boxes after rebuilding.
Author
Owner

The same production touch-target check found Chromium rounded the initially expanded short options to 43.984 px (--hit was 44 px), leaving them just below the minimum. I added a half-pixel expansion only on short axes to absorb engine inset quantization; the 31 focused tray tests pass. Fix committed as a0cc79281 and will be covered by the full browser profile.

The same production touch-target check found Chromium rounded the initially expanded short options to 43.984 px (`--hit` was 44 px), leaving them just below the minimum. I added a half-pixel expansion only on short axes to absorb engine inset quantization; the 31 focused tray tests pass. Fix committed as `a0cc79281` and will be covered by the full browser profile.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
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
kayg/calternal#1200
No description provided.