Skip to content

Latest commit

 

History

History
174 lines (141 loc) · 7.64 KB

File metadata and controls

174 lines (141 loc) · 7.64 KB

Backlog

Mouse-movement simulation framework — landed

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-trace parameterised 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 same dispatchPath infrastructure.
  • 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 same sim/diagnostics.js metrics so notebook and end-to-end agree. Substrate: Python + numpy + matplotlib in a notebook under notebooks/lens-design.ipynb; the JS picker stays the source-of-truth, the notebook is for fast iteration on the math before porting to JS.

Scientifically determine the required range

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.

Focal-pin status: v2 landed

The slot+pocket model + cascade-prone cursor mapping has been replaced in demos/palette/index.html and spherodeli/index.html by focal-pin v2:

  1. Unscaled list with translateY pin. bakeHeights is now called with budget = Infinity so heights are never scaled down. .fm-list is a fixed-height viewport (= budget) with overflow: hidden, wrapping a .fm-list-inner whose natural height is the raw sum (~1250 px for 148 items @ alpha=0.7). The inner translates by pin ∈ [budget − listH, 0].
  2. 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".
  3. 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.
  4. Wheel hand-off. wheel.deltaY translates 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.

Unified createPanel API with mode dispatch

Today the repo ships two algorithms as separate exports:

  • fisheye-core.jscomputeFisheyeHeights — cascading-menu lens (linear falloff within radius, weight normalization, deficit redistribution). Used by fisheye-menu.js / index.html. Reshapes on every mousemove.
  • baked-lens.jsbakeHeights + bakeCenters + rowFromY — flat-list Gaussian lens, baked once at open. Used by demos/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.

Bring the cascading library to a shared algorithm signature

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:

  • computeFisheyeHeights doesn't return centers; the panel relies on per-mousemove getBoundingClientRect() for hit-testing.
  • bakeHeights + bakeCenters return 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.