Skip to content

design: concentric corners for nested border radii - #317

Merged
nedtwigg merged 12 commits into
mainfrom
concentric-circles
Jul 28, 2026
Merged

design: concentric corners for nested border radii#317
nedtwigg merged 12 commits into
mainfrom
concentric-circles

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Jul 28, 2026

Copy link
Copy Markdown
Member

Focus-ring geometry and motion, in four layers (each with its own commits):

1. Concentric corners

The Concentric-Corners Rule (now named in DESIGN.md): when a rounded corner nests inside another rounded corner, both arcs share a corner center — outer radius = inner radius + gap, resolved by growing the outer radius, never tightening the inner. The focus ring was the reported case (ring inflated 3px past the pane's 8px corner while keeping — or shrinking — the pane radius, pinching the gap at every corner). A repo-wide sweep found and fixed two more: the mobile selector trays (4px chips at 4px padding in a 4px tray → 8px) and the KillConfirm target veil (4px over 8px pane corners → matched). Locked by design.test.ts assertions.

2. Gutter centering

The 1px passthrough ring can only sit dead-center of the pane gutter on whole pixels if the gutter is odd — so the gutter moved 6px → 7px (PANE_GUTTER_PX, feeding the Lath gap, Wall insets, and Baseboard padding). The ring inflate derives as (gutter+1)/2, measured pixel-exact at 6|2|6 device px; the marching-ants centerline sits on the same midline. (An 8px→6px pane-radius experiment rode this branch briefly and was reverted — 8px keeps the clean 2× interval against the 4px chrome step.)

3. Ring travel + header crossfade

Selection changes used to move the ring via a bare CSS transition on layout properties (browser-default ease) while the marching-ants path snapped to final geometry — visibly detaching mid-travel. The ring now travels via a JS tween (rect-tween.ts, reusing the Lath house easing) driven by a rAF loop: 220ms (FOCUS_MOTION_MS), tweening only on selection-identity change; same-identity updates (sash drag, resize, animator frames) snap 1:1 or retarget an in-flight tween without resetting its clock. The tween carries corner radii so pane↔door selection morphs shape. Both pane-header roots crossfade their active/inactive palette on the identical duration/curve (drift-guarded), so the color swap lands in step with the ring's arrival. Reduced motion / cfg.layout.animate=false snap through the shared motionIsInstant() gate.

4. Directional motion blur

The ring renderer unified into one SVG (SelectionRing, ants/solid variants — solid replaces the passthrough CSS border pixel-for-pixel). While travelling, an feGaussianBlur smears the ring along the travel axis, driven by EMA-smoothed per-frame velocity (sRGB filter interpolation so the smear keeps the ring's color; sigma capped at 3 — higher and a 1px stroke drops below ~7% peak alpha and vanishes). A trail (ghost-copies) variant was built, A/B'd live, and removed; directional shipped. Settled and reduced-motion rings carry no filter, so resting renders and Chromatic snapshots are unchanged.

Specs updated throughout (layout.md, tiling-engine.md, DESIGN.md — including the documented JS-tween carve-out to the "don't animate layout properties" rule). 1101 lib tests, spec-lint, and the pocket build pass.

🤖 Generated with Claude Code

Nested rounded corners must share a corner center: outer radius = inner
radius + gap, resolved by growing the outer radius, never tightening the
inner. Named in DESIGN.md (The Concentric-Corners Rule) and locked by a
design.test.ts assertion.

The focus selector was the reported case: the pane ring draws on a rect
inflated 3px past the pane's 8px corner but kept (passthrough) or even
shrank (marching ants, by half the stroke) the pane radius, pinching the
gap at every corner. New PANE_SELECTION_RING_RADIUS_PX (= pane radius +
SELECTION_RING_INFLATE_PX) feeds both ring modes; door rings and the Lath
drop preview sit at zero offset and keep the pane radius.

Swept the rest of the UI for nested radii; two more real cases fixed:
- Mobile touch/input selector trays: 4px chips at 4px padding inside a
  4px tray -> tray bumped to rounded-lg (8px).
- ModalOverlay's target-scoped veil: 4px over the pane's 8px corners at
  zero offset -> rounded-lg to match (previously masked by the borderless
  app-bg/50 fill).
Buttons centered well inside rounded rows/headers/dialogs don't corner-
hug and were left alone.

layout.md's selection-overlay geometry updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7721167
Status: ✅  Deploy successful!
Preview URL: https://8566f461.mouseterm.pages.dev
Branch Preview URL: https://concentric-circles.mouseterm.pages.dev

View logs

The 1px passthrough ring draws just inside its inflated rect, so in the
old 6px gutter it spanned [2,3] from the pane edge — half a pixel off
the gutter's centerline, a full device pixel on 2x displays. A 1px
stroke can only sit dead-center of a gutter on whole pixels when the
gutter is odd, so the gutter moves 6 -> 7 (new PANE_GUTTER_PX feeding
LATH_LAYOUT_OPTS.gap, the Wall/Baseboard inset classes, and the ring):

- SELECTION_RING_INFLATE_PX derives as (gutter+1)/2 = 4, so the border
  spans [3,4] — measured 6|2|6 device px at 2x, exactly centered.
- The marching-ants path inset now derives from the gutter too, putting
  its 2px stroke centerline on the same midline; the door ring keeps
  straddling the door edge.
- Ring radius stays concentric via the existing formula (pane 8 + 4).
- design.test.ts asserts the centering arithmetic and gutter oddness.

layout.md's gap/inset/overlay geometry updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nedtwigg and others added 4 commits July 27, 2026 17:55
TERMINAL_BORDER_RADIUS moves from Tailwind's lg step (0.5rem) to md
(0.375rem). Every consumer — pane headers, terminal/browser panel
bottoms, doors, the kill veil, the drop preview, and both focus-ring
modes — derives from the shared constants, so the Concentric-Corners
property holds automatically: the ring radius follows as 6 + 4 = 10 and
its stroke stays centered in the 7px gutter. DESIGN.md gains the md
(6px) radius token, and the door/pane prose moves to rounded-*-md.

Floating surfaces (dialogs, popovers) keep their own radii — this
touches only the pane/door corner system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selection changes moved the ring via a bare CSS transition on
top/left/width/height — 150ms browser-default ease (off the house
curve, against DESIGN.md's own layout-property rule, un-gated for
reduced motion) — while the marching-ants SVG path snapped to its final
geometry immediately, visibly detaching from the lerping box. The
header active/inactive palette swap was instant.

The ring now travels via a JS tween (new lib/src/lib/rect-tween.ts,
pure animator-style core reusing LATH_EASING) driven by a rAF loop in
WorkspaceSelectionOverlay: 220ms (FOCUS_MOTION_MS, half LATH_MOTION_MS)
on the house cubic-bezier(0.22,1,0.36,1). It tweens only when the
selection identity changes; same-identity updates (sash drag, window
resize, animator frames) snap 1:1 — or retarget an in-flight tween's
destination without resetting its clock, so the ring converges on
moving targets. The tween carries the corner radii + ants inset, so a
pane<->door selection morphs shape instead of popping, and the ants
path is recomputed every frame — glued to the box by construction.
Reduced motion / cfg.layout.animate=false snap through the same path
the Lath engine uses. The old `animating` flag is gone; only the
unfocus-saturate fade rides a CSS transition.

Both header roots (and the zoom pill) now crossfade their palette with
HEADER_PALETTE_TRANSITION_CLASS — same 220ms house curve, nulled under
motion-reduce — so the color swap lands in step with the ring's
arrival. A drift-guard test ties the class literal to FOCUS_MOTION_MS.

Verified live: mid-tween frames show the ring in flight between panes
with intact corners, and header pixels mid-blend between the active and
inactive pairs, landing exactly on the final palettes as the ring
settles. 1101 lib tests (18 new across rect-tween, overlay, and drift
guard), spec lint, and the pocket build pass. layout.md /
tiling-engine.md / DESIGN.md updated (including the JS-tween carve-out
to the animate-layout-properties rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/simplify findings on the previous commit:

- The snap gate (!cfg.layout.animate || prefersReducedMotion()) existed
  verbatim in the overlay and lath-wall-engine, synced by prose. It is
  now one named predicate, motionIsInstant() in ui-geometry.ts, and the
  overlay evaluates it once per effect run instead of per streamed
  frame (dropping a matchMedia construction from the per-frame path).
- DOOR_SELECTION_BORDER_RADIUS and PANE_SELECTION_RING_BORDER_RADIUS
  became dead when the overlay switched to numeric shape channels —
  deleted along with their now-tautological test assertions.
- Dropped a can't-fire guard clause (frame and identity refs are kept
  in lockstep by snapTo/clear).

Skipped by design: the one-line lerp duplicate (animator's is private;
the parallel implementation is documented), and the per-frame
getTotalLength during travel (~13 calls per 220ms on a trivial path;
the analytic replacement isn't worth the complexity).

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

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One stale spec reference from the 6px→7px gutter change. The code now sets LATH_LAYOUT_OPTS gap to PANE_GUTTER_PX (7), and you updated the matching line in layout.md, but tiling-engine.md still says gap 6. Suggestion inline.

Otherwise this reads cleanly — the tween core is well-factored and the concentric-corners derivation is nicely locked down by tests.

Comment thread docs/specs/tiling-engine.md Outdated
nedtwigg and others added 6 commits July 27, 2026 21:45
The plain tween is decent but not amazing — build both motion-treatment
candidates behind cfg.focusRing.motionBlur so they can be compared live:

- MarchingAntsRect -> SelectionRing, one SVG renderer for both ring
  modes: variant 'ants' (existing dashed stroke) and 'solid' (1px, no
  dash), the latter replacing the passthrough CSS border pixel-for-pixel
  (centerline still on the gutter midline; door parity preserved).
- The overlay's rAF tick derives per-frame center velocity and keeps a
  short trail buffer; 'directional' drives an feGaussianBlur whose
  per-axis stdDeviation scales with speed, 'trail' renders fading ghost
  copies. A settled or reduced-motion ring carries neither, so resting
  renders (and Chromatic) are byte-identical across modes.
- Three Wall stories pin each mode for side-by-side feel; cfg default
  is 'directional' for dogfooding.
- blurMaxPx capped at 3 after still-frame verification: a 1px stroke at
  sigma 6 drops to ~7% peak alpha and vanishes mid-flight.

Verified live per mode: directional shows stdDeviation "0 6"->"0 3"
scaled by travel speed and no filter at rest; trail ramps 1->4 paths
mid-flight and back to 1 settled; baseline unchanged. 1103 lib tests,
spec lint, pocket build pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The focus-ring motion-blur A/B (b769359) landed directional as the clear
winner. Collapse the experiment down to just the shipped treatment:

- Remove the `cfg.focusRing.motionBlur` enum, the trail ghost buffer +
  `buildGhosts`, the `RingGhost` type/prop/rendering, and the three
  `FocusTravel*` comparison stories + decorator. Directional blur is now
  simply how a travelling ring renders; a settled or reduced-motion ring has
  null velocity and never blurs, so Chromatic stays deterministic.
- Keep the two quality wins from tuning: EMA velocity smoothing
  (`cfg.focusRing.blurSmoothing`) to shed rAF frame-timing jitter, and
  `color-interpolation-filters="sRGB"` so the blurred smear keeps the ring's
  true color instead of the linearRGB default's muddying.

cfg.focusRing is now three tunables (blurGain / blurMaxPx / blurSmoothing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three rounds of Safari-chop hunting, resolved by measurement:

- Filter region: userSpaceOnUse hugging the ring (~3-sigma pad) instead
  of the objectBoundingBox default that rasterized ~4x the pane area on
  WebKit's CPU filter path.
- Imperative ring driver: SelectionRing is now a stable structural shell
  (rendered once per variant/color change); the overlay's rAF loop
  writes geometry, path d, dash sizing, and blur attributes straight to
  the DOM — zero React reconciliation per frame, the same
  React-owns-structure / frame-owns-mutations split as the Lath
  animator (recorded in layout.md).
- Frame-time measurement via safaridriver then showed Safari at a
  locked 60fps in EVERY config (SVG blur, no blur, CSS blur: p95 <=
  18ms, zero frames over 25ms) — the perceived jank was never dropped
  frames. The actual discontinuity: the filter attached only above a
  sigma threshold, popping the ring crisp->blurred->crisp at each end
  of the 220ms glide. The filter now stays attached for the whole
  travel with sigma running continuously to zero (verified: 3 -> 2.1 ->
  1.3 -> 0.7 -> 0.4 -> 0.2 -> 0.1 -> detach), and the velocity EMA
  seeds from zero instead of the first sample. A settled ring still
  carries no filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The imperative ring driver shares one <path> across both variants, and
the marching-ants dash is an attribute write React never reconciles
away — so after visiting command mode once, the passthrough ring
rendered its 1px solid stroke with the stale dasharray (a thin dotted
line). applyRing now clears stroke-dasharray and --march-offset for the
solid variant; regression-tested with a command->passthrough flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the PR #317 review comment — LATH_LAYOUT_OPTS.gap is
PANE_GUTTER_PX (7) since the gutter-centering change; this line still
said 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit 06f3690 into main Jul 28, 2026
11 checks passed
@nedtwigg
nedtwigg deleted the concentric-circles branch July 28, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants