design: concentric corners for nested border radii - #317
Merged
Conversation
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>
Deploying mouseterm with
|
| Latest commit: |
7721167
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8566f461.mouseterm.pages.dev |
| Branch Preview URL: | https://concentric-circles.mouseterm.pages.dev |
dormouse-bot
approved these changes
Jul 28, 2026
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>
dormouse-bot
approved these changes
Jul 28, 2026
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>
This reverts commit 52b65ef.
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
left a comment
Collaborator
There was a problem hiding this comment.
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.
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>
dormouse-bot
approved these changes
Jul 28, 2026
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsassertions.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=falsesnap through the sharedmotionIsInstant()gate.4. Directional motion blur
The ring renderer unified into one SVG (
SelectionRing,ants/solidvariants — solid replaces the passthrough CSS border pixel-for-pixel). While travelling, anfeGaussianBlursmears 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