Six profiles run a Playwright driver against both the demo and
spherodeli, scoring assertions and writing per-test JSON summaries
that npm run sim:report aggregates into docs/sim-report/index.md.
All twelve tests pass on both consumers under focal-pin v2 (see
"Focal-pin status" below).
Profiles in sim/profiles/:
settling-1x— ±1 critically-damped settle.flick-then-correct— ballistic overshoot + corrective settle (the canonical cascade repro before focal-pin v2 landed).scan-and-pick— top-to-bottom sweep with dwells (reachability).slow-trace— constant 50 px/s sweep (per-event ≤1-row jump gate).jitter-at-target— 1 px / 5 Hz tremor (pocket invariant).back-and-forth— ±1 oscillation across 5 cycles (no re-bake).
Path generators accept yForRow (the picker's linear cursor → focal
map) — they no longer assume the row centers double as cursor-y
targets, which broke once focal-pin v2 made the natural list taller
than the viewport.
Still open in the sim framework:
- Variable-velocity sweep (the
slow-traceparameterised across 50/150/400/1000 px/s) for finding the cascade's velocity threshold. - Marketing-artifact capture path (screenshots, video, badges) per
docs/SIMULATION-SPEC.md— opt-in render targets driven by the samedispatchPathinfrastructure. - Optimizer over (alpha, maxH, STABLE_DIST) using the assertion outcomes as a fitness function.
- Lens-design Jupyter notebook. Reach for this once we want to
explore mapping functions (velocity-adaptive, anchored-intent,
hybrid local-slot + global-linear) WITHOUT running a Playwright
loop per iteration. Inputs: N, budget, alpha, maxH, candidate
focalForCursor(cursorY, pin, history)function. Outputs: aim error per row, reachable-row count, reshape amplitude per cursor velocity, plotted as small multiples. Plugs into the samesim/diagnostics.jsmetrics so notebook and end-to-end agree. Substrate: Python + numpy + matplotlib in a notebook undernotebooks/lens-design.ipynb; the JS picker stays the source-of-truth, the notebook is for fast iteration on the math before porting to JS.
The README cites Fitts (1954), Bederson (2000), Cockburn & Gutwin
(2007), Accot & Zhai (1997), Ahlström et al. (2005). The
Cockburn-Gutwin result is the load-bearing one: Dock-style fisheye
menus tested slower than traditional menus because magnification
distorted spatial layout. Our STABLE_DIST=1 pocket + the (proposed)
focal-pin redesign address that critique by preserving "where the
target was" through the bake shift.
From the references, derive:
- Minimum focal-row pixel height for Fitts-comfortable targeting (W > some threshold; the steering-law derivation in Accot-Zhai bounds the per-row time cost as a function of width).
- Acceptable per-step magnification ratio (Bederson's empirical ranges; we'd want to land inside their tested band).
- Maximum acceptable spatial distortion (Cockburn-Gutwin's failure mode: when target jumps by more than ~one row's height, performance degrades).
Use these to set hard bounds on the simulation's optimizer search.
The slot+pocket model + cascade-prone cursor mapping has been
replaced in demos/palette/index.html and spherodeli/index.html
by focal-pin v2:
- Unscaled list with translateY pin.
bakeHeightsis now called with budget =Infinityso heights are never scaled down..fm-listis a fixed-height viewport (= budget) withoverflow: hidden, wrapping a.fm-list-innerwhose natural height is the raw sum (~1250 px for 148 items @ alpha=0.7). The inner translates bypin ∈ [budget − listH, 0]. - Linear cursor → focal mapping.
focalForCursor(y) = round(y/budget * (N-1)). Cursor at the top ⇒ focal=0, cursor at the bottom ⇒ focal=N-1. Stable invariant contract: never depends on the current bake (which was the chicken-and-egg at the root of the cascade), and naturally gives every row a reachable cursor position. The picker exposes_yForRow[k]so external testers know "where to move the cursor to focus row k". - Pin update on every move.
pin = clamp(cursorY - centers[bakedFocus], budget - listH, 0). Inside the STABLE_DIST=1 pocket the pin still tracks the cursor smoothly (no re-bake; list slides under the cursor); outside the pocket the rebake fires AND the pin re-anchors so the new focal sits exactly at the cursor's current y. The cascade — bake-shift sliding the row out from under the cursor — is structurally prevented. - Wheel hand-off.
wheel.deltaYtranslates the inner directly. Cursor's apparent focal updates from its viewport y. The user can scroll independently of cursor motion.
Validation: 12/12 sim tests pass on both consumers; max
per-frame focal jump is 1; >1 jumps = 0 across all profiles. See
docs/sim-report/index.md.
The TODO #2 (cursor-at-edge auto-scroll) from the old plan turned out to be unnecessary — the linear mapping gives full reachability without it. Wheel hand-off (#3) shipped as a small addition.
One layout detail worth noting: the menu's max-height: 92vh CSS
constraint had to come off — with the unscaled inner taller than
the available viewport, the parent's max-height clip was hiding
the bottom rows from pointer hit-testing. The JS already caps
_budget to the available height, so the max-height was redundant
and harmful.
Today the repo ships two algorithms as separate exports:
fisheye-core.js→computeFisheyeHeights— cascading-menu lens (linear falloff within radius, weight normalization, deficit redistribution). Used byfisheye-menu.js/index.html. Reshapes on every mousemove.baked-lens.js→bakeHeights+bakeCenters+rowFromY— flat-list Gaussian lens, baked once at open. Used bydemos/palette/.
Consumers currently pick by import path, which surfaces the algorithm choice as an architectural concern even when the user only cares about the UX mode.
Proposed dispatcher:
createPanel(items, {
mode: 'cascading' | 'baked', // default: heuristic
// shared: items, onSelect, theme, debug, ...
});When mode is omitted, default by a heuristic:
const looksFlat = !items.some(it => it && it.children?.length);
const mode = (looksFlat && items.length > 40) ? 'baked' : 'cascading';Rationale: "flat list & N > 40" is shorthand for "the per-event reshape distance has crossed the perceptual threshold" — at small N the cheese motion is imperceptible, at large N it's disorienting. Hierarchical menus need flyout steering which only the cascading algorithm supports, so we never default flat.
Not blocking on this — both algorithms work standalone and the demo proves the baked path. Consolidate when there's a real consumer that would benefit from the unified surface.
Once the dispatcher lands, the two algorithms should ideally share a
common output contract — { heights, centers } plus a rowFromY-style
hit-test — so the panel controller doesn't fork on mode beyond the
initial bake/rebuild decision. Today they diverge:
computeFisheyeHeightsdoesn't return centers; the panel relies on per-mousemovegetBoundingClientRect()for hit-testing.bakeHeights+bakeCentersreturn both; the consumer binary-searches centers without touching the DOM.
A future fisheye-core.bake() that returns the cascading-mode
equivalent would let the panel controller use one cursor → row code
path regardless of mode.