the lightbox's engine without React: zoom, pan, momentum, one spring
npx shadcn add @ag/lightbox-motionAlready installed? Preview an update
npx shadcn add @ag/lightbox-motion --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-15
- New file:
slide-track(createSlideTrack,SLIDE_TRACK,SLIDE), the lightbox's slide track out of the lightbox: a row of slides in a real scroll container driven by one writer for every input, one slide a gesture, held at both ends, a glide that starts where the slides stand. The lightbox runs on it unchanged (the same wheel gestures, before and after, land on the same slides with no overshoot), andtabsswipes on it. - Breaking:
SLIDE_GAPis removed. The track reads the room between slides from its own CSSgap(the lightbox's stays 32px inlightbox.css): a gap kept as a number beside the CSS drifted the track off every slide where a rem is not 16px. Migration: set the gap on the track in CSS. - A slide tips past its halfway point (
SWIPE_TIP), so where a swipe will land is on the screen before it does. A finger holds the picture 1:1 and on release it lands where the motion would have coasted (swipeTip), so a quick short flick turns it as a native pager does. A wheel moves the slides byswipeGain: the same hand a slide cost before (swipeCommitPx, 225 px on a wide screen) now carries the picture to its halfway point, where it tips. Every move after the hand lets go is a fall: it leaves at the speed the slides were moving, never a jump, speeds up and lands, from the tip inCARRY_MAX(240 ms) and sooner the shorter the way (carryMs), shortened under a hand faster than that. Before, the slides leaned toward an invisible line and either crept and wound back or shot off from 13% across. A wheel's gesture ends the moment the hand lifts, read off its speed: once its pace (the mean size of its last 6 deltas,swipePace) falls under half its own peak (SWIPE_LIFT) while the wheel phase sees the device's coast, the trackpad is coasting (a slow drag never lifts early: it ends on silence), and the slide decides there and the coast moves nothing; after any decision, a pace rising by half over its lowest since is the hand pushing again, and a new swipe (swipeAgain). Before, the coast inched the slides on for half a second and then wound them back. Breaking:SWIPE_LEANandswipeGiveare removed;SlideTrack.wheel(dx, handBack, coasting)takes the wheel phase's read and no speed (the track measures its own), andSlideTrack.glideTo(i)none either. - A hand back on the trackpad inside the last swipe's coast is a new swipe: the wheel phase's
startbreaks a spent gesture, beside the surge and the turn, so a second flick the same way is no longer swallowed as tail.SlideTrack.wheeltakes it ashandBack. lightbox-wheel-phaseopens a new stream when a delta turns to the other axis (AXIS_TURN, inside a stream alreadyAXIS_HELDalong one): a coast never changes axis, so a swipe down straight after a sideways one is read at once instead of going to the sideways track until the coast was cut short.PhaseRead.whysays why a stream opened (first,silence,interrupted,turned).lightbox-wheel-phaseends a stream afterSWIPE_ENDof silence. It never ended one before, so after a slow sideways swipe with no coast the lightbox read the next stream's axis off the old swipe's travel: a swipe up to dismiss went on sideways.createSlideTracktraces every decision with the engine's whole state when itsdebugis on, and watches the scroller once a frame for the browser moving it, snapping held off with nothing driving, and a rest between two slides. Its pitch is read from the layout's width, not the drawn one, so a track measured while it scales in no longer keeps a wrong pitch.
spring · machine ζ 1 · f 4.5 hz · settles 275 ms
Enter, exit, zoom, pan recovery, step: critically damped, no overshoot.
spring · hand ζ 0.82 · f 4.5 hz · settles 267 ms
Dismiss cancel and slide commit: a little weight past the target.
spring · coast ζ 1 · f 0.80 hz · a 2 px/ms flick settles 1333 ms
Momentum after a release: ω = 1/199, the decay the projection assumes, so the image never outruns the hand. The faint trace is the same flick under MACHINE: it kicks to twice the hand's speed first.
zoom rubber · 0.5 → 3 over [1, 2]
Soft under fit (0.15), stiff over the ceiling (0.05); the spring returns it.
cover clip · trigger aspect 0.5 → 3 over a 3:2 fit
The vertical inset that crops the fit box into the trigger's shape; zero where aspects match.
the demo above, verbatim
import { Sample } from "@/app/samples"
import {
COAST,
HAND,
MACHINE,
project,
rubber,
Spring,
sourceView,
type Tuning,
} from "@/registry/base-nova/lib/lightbox-motion"
const W = 320
const H = 120
function trace(
tuning: Tuning,
target = 100,
vel = 0,
seconds = 1,
): { points: string; ms: number } {
const s = new Spring<"v">({ v: 0 }, { v: 0.5 })
s.aim({ v: target }, tuning, { v: vel })
const pts: string[] = [`0,${H}`]
let t = 0
for (let i = 0; i < 120 * seconds; i++) {
const done = s.step(8.333)
t += 8.333
pts.push(
`${(t / 1000 / seconds) * W}, ${H - (s.value.v / target) * (H / 1.2)}`,
)
if (done) break
}
return { points: pts.join(" "), ms: t }
}
function curve(
f: (x: number) => number,
from: number,
to: number,
scale: number,
) {
const pts: string[] = []
for (let i = 0; i <= 64; i++) {
const x = from + ((to - from) * i) / 64
pts.push(`${(i / 64) * W},${H - f(x) * scale}`)
}
return pts.join(" ")
}
function Plot({
title,
note,
children,
}: {
title: string
note: string
children: React.ReactNode
}) {
return (
<div className="space-y-2">
<div className="font-mono text-xs text-muted-foreground">{title}</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full rounded-xl border border-border bg-sidebar"
role="img"
aria-label={title}
>
{children}
</svg>
<p className="text-sm text-foreground/55">{note}</p>
</div>
)
}
export default function Demo() {
const machine = trace(MACHINE)
const hand = trace(HAND)
const coast = trace(COAST, project(0, 2), 2, 2)
const kick = trace(MACHINE, project(0, 2), 2, 2)
const clip = (aspect: number) =>
sourceView(
{ x: 0, y: 0, w: 100, h: 100 / aspect, radius: 0 },
{ w: 300, h: 200 },
{ top: 0, left: 0, w: 1000, h: 800 },
).clip.h
return (
<div className="grid gap-8 sm:grid-cols-2">
<Sample name="machine" glance title="machine spring">
<Plot
title={`spring · machine ζ 1 · f 4.5 hz · settles ${Math.round(machine.ms)} ms`}
note="Enter, exit, zoom, pan recovery, step: critically damped, no overshoot."
>
<polyline
points={machine.points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</Plot>
</Sample>
<Sample name="hand" title="hand spring">
<Plot
title={`spring · hand ζ 0.82 · f 4.5 hz · settles ${Math.round(hand.ms)} ms`}
note="Dismiss cancel and slide commit: a little weight past the target."
>
<polyline
points={hand.points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</Plot>
</Sample>
<Sample name="coast" title="coast spring">
<Plot
title={`spring · coast ζ 1 · f ${COAST.f.toFixed(2)} hz · a 2 px/ms flick settles ${Math.round(coast.ms)} ms`}
note="Momentum after a release: ω = 1/199, the decay the projection assumes, so the image never outruns the hand. The faint trace is the same flick under MACHINE: it kicks to twice the hand's speed first."
>
<polyline
points={kick.points}
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="1"
/>
<polyline
points={coast.points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</Plot>
</Sample>
<Sample name="rubber" title="zoom rubber">
<Plot
title="zoom rubber · 0.5 → 3 over [1, 2]"
note="Soft under fit (0.15), stiff over the ceiling (0.05); the spring returns it."
>
<polyline
points={curve((x) => rubber(x, 1, 2), 0.5, 3, H / 3)}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<line
x1="0"
x2={W}
y1={H - H / 3}
y2={H - H / 3}
stroke="currentColor"
strokeOpacity="0.15"
/>
<line
x1="0"
x2={W}
y1={H - (2 * H) / 3}
y2={H - (2 * H) / 3}
stroke="currentColor"
strokeOpacity="0.15"
/>
</Plot>
</Sample>
<Sample name="cover-clip" title="cover clip">
<Plot
title="cover clip · trigger aspect 0.5 → 3 over a 3:2 fit"
note="The vertical inset that crops the fit box into the trigger's shape; zero where aspects match."
>
<polyline
points={curve((a) => clip(a), 0.5, 3, H / 60)}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</Plot>
</Sample>
</div>
)
}the source, every file an install writes
// The lightbox's math, framework-free: geometry (fit, source view, stage band), the
// gesture rules (zoom at a point, rubber, pan bounds, momentum, commit tests) and one
// interruptible spring. Numbers in, numbers out; the component owns the DOM and the
// clock. `bun scripts/examples/lightbox-motion.ts` exercises every invariant.
export type View = { x: number; y: number; s: number }
/** The view plus `p`, how far the room is lit: 0 at the source, 1 at rest. */
export type Pose = View & { p: number }
export type Size = { w: number; h: number }
export type Rect = {
x: number
y: number
w: number
h: number
radius: number
}
export type Band = { top: number; left: number; w: number; h: number }
export type Point = { x: number; y: number }
export type Tuning = { zeta: number; f: number }
/** One tuning for every axis, or one per axis: a flick that coasts on x while y
* bounces off a wall runs COAST and MACHINE side by side. */
export type Tunings<K extends string> = Tuning | Readonly<Record<K, Tuning>>
export function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(`lightbox: ${msg}`)
}
/** Machine moves: enter, exit, zoom, pan recovery, step. */
export const MACHINE: Tuning = { zeta: 1, f: 4.5 }
/** Hand releases: dismiss cancel, slide commit. A little weight. */
export const HAND: Tuning = { zeta: 0.82, f: 4.5 }
/** Reduced motion: the target is assumed at once. */
export const STILL: Tuning = { zeta: 1, f: Infinity }
export const FIT: View = { x: 0, y: 0, s: 1 }
/** Exit target when the trigger left the page: a cut, never an abort. */
export const GONE: View = { x: 0, y: 0, s: 0.92 }
export const MOMENTUM = 199
/** A free coast after a flick: the projected rest is `v · MOMENTUM` away, and a
* critically damped spring whose ω = 1/MOMENTUM is the exact exponential decay that
* projection assumes, so the hand's velocity is never exceeded. A stiffer spring
* aimed that far away accelerates first (2.1x the release speed with MACHINE). */
export const COAST: Tuning = { zeta: 1, f: 1000 / (2 * Math.PI * MOMENTUM) }
export const INTENT = 6
export const RELOCK = 12
export const OVERSHOOT = 0.35
export const TAP_TRAVEL = 4
export const DISMISS_COMMIT = 0.4
/** How far the room must have gone before the page takes the rest of the momentum:
* `p` is how lit the room still is, so this is the page being half revealed.
*
* BOTH OR NEITHER. Deciding to leave is not the same instant as being able to see
* where you are going, and the decision comes early — a dismiss commits from a
* projection, with the picture barely moved and the backdrop still up. Scrolling
* there means the page moves behind a curtain, which is exactly the loss of control
* the handoff was meant to fix. Waiting for the whole flight to land is the other
* failure and reads as a delay. This is the moment they become one motion. */
export const EXIT_HANDOFF = 0.5
export const PINCH_CLOSE = 0.75
export const PINCH_PASSED = 1.067
/** How long a slide takes to arrive, for one slide's worth of distance, scaled by the
* square root of how far it actually has to go, and bounded. A move ARRIVES, which a
* spring never does: the tail of an approach is a crawl a few px short of home. This
* is what the arrow keys feel like, and the swipe uses it too, because the platform's
* own snap settle is ~950 ms across a slide in Chromium (deltas decaying 0.92 a frame
* until the last one is under a pixel) and no faster on macOS WebKit. */
export const GLIDE = 165
export const GLIDE_MIN = 100
export const GLIDE_MAX = 225
/** A move LEAVES, and the entry tangent says how hard, as a multiple of the average
* speed the move needs. Both ends matter.
*
* The floor is why: handed a slow hand's speed, the cubic below is a smoothstep, and
* a smoothstep starts at a standstill. Measured on a real swipe, the handover speed
* was 6% of the move's own average, so the pictures hung for a beat and then bolted.
* Never slower than a quadratic ease-out: out at twice the average, easing in.
*
* The cap is the monotonicity bound. m0 = 3d is exactly cubic ease-out; past it the
* cubic turns back on itself before it arrives, and the pictures would visibly
* retreat at the end of every fast throw. */
export const GLIDE_ENTRY_MIN = 2
export const GLIDE_ENTRY = 3
/** How long a move lasts once the hand has let go, ms: a FALL. The slides leave at
* the hand's own speed (`glideTo`'s `carry`, never a jump) and gravity takes them
* the rest of the way, faster, to land: from the tip in `CARRY_MAX`, a shorter way
* sooner (a fall's time goes as the square root of its height, the share of the
* tip). A hand faster than that leaves no room to speed up in: the move is
* shortened so it never slows under the hand (`GLIDE_ENTRY` times its average),
* which keeps a flick, and a run of them, quick.
*
* Three rounds in the gym (2026-10-15). `GLIDE`'s key bounds took 800 px at
* 1.9 px/ms in 119 ms, peaking near 10: "it doesn't move fluently". As long as the
* way took at the hand's speed was 400 ms of the slides moving on their own after
* the lift: "I don't like all the action that happens when I lift my finger...
* gravity pulled faster, tipping either one way or the other". */
export const CARRY_MAX = 240
export function carryMs(d: number, v: number, slideW: number): number {
assert(slideW > 0, `a slide of ${slideW}`)
const share = Math.min(1, Math.abs(d) / (SWIPE_TIP * slideW))
const fall = CARRY_MAX * Math.sqrt(share)
const fast =
v * d > 0
? (GLIDE_ENTRY * Math.abs(d)) / Math.abs(v)
: Number.POSITIVE_INFINITY
return clamp(Math.min(fall, fast), GLIDE_MIN, CARRY_MAX)
}
/** Share of a slide's width a WHEEL's hand pays for it (`swipeGain` maps that hand
* to the slide's tip). Embla's number: it commits past `clamp(20% of the viewport,
* 50, 225)` px, which on a wide screen is about this.
*
* It is the price of EVERY slide, because the gesture re-anchors on each one: the
* first, the fifth and the one straight back the other way all cost exactly this
* much finger. The alternative, a slide read off the TOTAL travel from a fixed
* origin, is not a threshold at all but an absolute map, and it is asymmetric in a
* way a hand feels immediately: measured, the second slide cost a full slide of
* finger while undoing the first cost 7 px. */
export const SWIPE_COMMIT = 0.18
/** A swipe projects with the lightbox's own coast (`project`, off `MOMENTUM`), and
* only once it ends. Until then a trackpad's momentum arrives as deltas and is
* travel; at the lift (`SWIPE_LIFT`) the coast is under way and still slowing, and
* where it would have come to rest is where the slides come to rest (`swipeTip`).
* There was a `THROW` constant once, projecting a wheel from the hand's speed while
* the coast was still to come; the lift is what makes the projection exact enough. */
/** ONE GESTURE BUYS ONE SLIDE. Past the tip the pictures go there and stay there;
* everything the device sends afterwards is ignored until the stream is over.
*
* Skipping several on a hard flick was tried twice, flat-priced and then on a
* doubling ladder, and both are a slot machine. The boundary between one and two sits
* at some number of pixels of finger, and a reader cannot feel pixels of finger:
* measured, 0.46 slides of travel took one picture and 0.63 took two, which is
* correct arithmetic and indistinguishable to the hand that did it. Worse, the same
* rule is what let a gesture keep leaning toward a slide it had not bought, which is
* the drift past the snap point that made the whole thing feel alive.
*
* Every native pager decided this the same way: UIScrollView's `isPagingEnabled`,
* Android's `PagerSnapHelper`, Embla with `skipSnaps` off. Three pictures is three
* flicks, and three flicks is a second's work and never a surprise. */
/** A SLIDE TIPS PAST ITS HALFWAY POINT, the one rule a reader already knows: a card
* pushed past half falls to the other side, short of it falls back. So the outcome
* is on the screen before it happens, never a line nobody can see.
*
* The gym measured why (2026-10-15): with the slides following a trackpad 1:1 and a
* slide bought at half of `swipeCommitPx`, 198 px of hand moved a 1533 px picture
* 13% and it then shot the other 87% to the next: "chooses to go into next frame
* for no reason". Before that the slides leaned (quadratic) toward an invisible
* line and 215 px of hand wound back: "atrociously slow". Both hid the outcome. */
export const SWIPE_TIP = 0.5
/** How far a WHEEL moves the slides per px of hand: the hand a slide costs
* (`swipeCommitPx`) carries it to its tip. A trackpad is not the picture under the
* fingers, so its cost is free to be the hand's (225 px on a wide screen, the most a
* deliberate swipe reaches) while the picture shows the honest half. A finger IS on
* the picture: it moves the slides 1:1, never by this. */
export function swipeGain(slideW: number): number {
assert(slideW > 0, `a slide of ${slideW}`)
return (SWIPE_TIP * slideW) / swipeCommitPx(slideW)
}
/** Where a gesture lands, as a step from its slide: -1, 0 or 1. The slides at
* `offset` px moving at `v` px a ms come to rest where they would have coasted
* (`project`), and tip if that is past half. One slide at most, whatever the throw. */
export function swipeTip(offset: number, v: number, slideW: number): number {
const rest = project(offset, v)
return Math.abs(rest) >= SWIPE_TIP * slideW ? Math.sign(rest) : 0
}
/** THE LIFT. A wheel never says the fingers left, but its pace does: a hand moves
* at its own, and the moment it lifts the device takes over and only ever slows.
* So a gesture whose pace falls below `SWIPE_LIFT` of its own peak is a hand gone
* (or stopping, which ends it the same), and it decides there. A share of the
* hand's OWN pace, so a slow swipe and a flick lift alike, on any refresh rate.
*
* The pace is px an EVENT, never px a ms. Hand and coast both arrive once a
* frame; what a coast changes is each event's size. Time would read a stalled
* frame as the hand slowing, where the browser hands the late travel over as one
* BIGGER delta: by the event, a stall only ever reads faster.
*
* Measured in the swipe gym (2026-10-15): a slow swipe at 3-4 px a frame, then
* 2, 2, 1, 1, 0 over 600 ms after the lift, the slides inching the whole time and
* then winding back. The phase detector could not see it: it takes RATIOS of
* 1-2 px deltas, too coarse to fall inside its decay band. A mean of several
* sums them. */
export const SWIPE_LIFT = 0.5
/** How many events the pace is the mean of: 48 ms at 125 Hz, 100 at 60. */
export const SWIPE_LIFT_EVENTS = 6
/** A COAST NEVER SPEEDS UP. Once a gesture is decided, however (a lift, a tip), a
* pace rising by `SWIPE_AGAIN` over its lowest since, and past `SWIPE_AGAIN_MIN`
* px an event (a tail's 1-2 px jitter), is the hand pushing again: a new gesture.
* A share and not a double, because a pace is a mean of six and moves smoothly: a
* second push of 6 px an event over a 2-3 px tail was swallowed by a doubling, and
* by a rule asking it to match half the first push's 43 (the gym, 2026-10-15:
* "why didn't it move at all when I swiped there?"). */
export const SWIPE_AGAIN = 1.5
export const SWIPE_AGAIN_MIN = 2
export function swipeAgain(pace: number, low: number): boolean {
return pace > Math.max(SWIPE_AGAIN_MIN, SWIPE_AGAIN * low)
}
/** The pace, px an event: the mean size of the last `SWIPE_LIFT_EVENTS` deltas,
* newest last, or null until there are that many. Magnitudes: the lift is the
* hand slowing, whichever way it goes. */
export function swipePace(deltas: readonly number[]): number | null {
if (deltas.length < SWIPE_LIFT_EVENTS) return null
let sum = 0
for (const d of deltas.slice(-SWIPE_LIFT_EVENTS)) sum += Math.abs(d)
return sum / SWIPE_LIFT_EVENTS
}
/** A swipe is over after this long without a wheel event. Shorter than the wheel
* session's own window, which is doubled while a hand may still be down because
* ending a zoom early is bad. Ending a swipe early is nearly free: the give winds
* back, the slides it bought are already bought, and a hand that comes back starts
* from scratch, which is what it should do anyway. */
export const SWIPE_END = 110
/** A gesture that has already bought its slide is over, but the device keeps sending
* for most of a second. A tail only ever DECAYS, so a delta this many times the
* envelope of the ones before it is a hand back on the glass and a new gesture — the
* one thing about a wheel stream that can be read without guessing, unlike the
* release, which cannot. Without it a second flick inside the first one's tail does
* nothing at all. */
export const SWIPE_SURGE = 2
export const SWIPE_SURGE_MIN = 4
/** How fast the envelope forgets, per event. */
export const SWIPE_SURGE_DECAY = 0.9
export const swipeEnvelope = (dx: number, env: number): number =>
Math.max(Math.abs(dx), env * SWIPE_SURGE_DECAY)
/** THE TWO THINGS A WHEEL STREAM SAYS PLAINLY. It never says when the fingers left.
*
* A tail only DECAYS: a delta over `SWIPE_SURGE` times the envelope of the ones
* before it is a hand back on the glass. And momentum never REVERSES: a delta
* against the direction the gesture just bought is a hand, with certainty rather than
* by inference. Either ends the spent gesture and opens the next one. The phase
* detector's `start` (a coast cut short against the LAST delta, not an envelope) is
* the third, and the slide track takes it too: measured in the swipe gym, a second
* flick the same way was seen by it 710 ms before the surge saw it, and the 2604 px
* the hand moved in between were swallowed as tail.
*
* Both are needed. Magnitude alone cannot see a reversal — measured, a reader swiped
* forward, the slide committed, and they swiped straight back 170% of a slide with
* every delta of it smaller than the flick before, and the whole thing was swallowed
* as tail. `dir` is 0 before anything is bought, when nothing needs interrupting. */
export function swipeBreak(
dx: number,
env: number,
dir: number,
): "surge" | "turned" | null {
if (dir !== 0 && Math.sign(dx) === -dir) return "turned"
if (Math.abs(dx) > Math.max(SWIPE_SURGE_MIN, SWIPE_SURGE * env))
return "surge"
return null
}
/** Embla's numbers, all three of them. A SHARE of the slide, but clamped in absolute
* px, because a share alone stops being a gesture on a wide screen: 18% of a 1560px
* slide is 280px of finger, and a reader making small deliberate trackpad movements
* never gets there. Measured: 766 ms of hand, 0.123 slides, release at 0.17 px/ms,
* projected to 277px against a 281px line, and the pictures did not move.
*
* The floor matters on a phone for the same reason in reverse: 18% of a narrow slide
* is a few px, and a tap with a tremor in it should not page. */
export const SWIPE_COMMIT_MIN = 50
export const SWIPE_COMMIT_MAX = 225
export function swipeCommitPx(slideW: number): number {
return clamp(SWIPE_COMMIT * slideW, SWIPE_COMMIT_MIN, SWIPE_COMMIT_MAX)
}
/** A held arrow pans at this speed (px per ms); two arrows add up to a diagonal. */
export const KEY_PAN_SPEED = 0.9
/** A held + or - doubles (halves) the zoom every this many ms. */
export const KEY_ZOOM_MS = 700
export const WHEEL_GUARD = 400
export const WHEEL_ZOOM = 0.01
/** One wheel event moves at most this many px: a mouse notch (100 px in Chrome) is a
* nudge, a trackpad tick (1-30 px) is untouched. Bounds ctrl+wheel zoom to 1.35x. */
export const WHEEL_TICK_MAX = 30
/** A finger still for this long before lifting has no momentum. */
export const STOP_GAP = 50
/** Release velocity is the mean over this span; a gesture keeps SAMPLES points,
* enough to cover it at 120 Hz. */
export const VELOCITY_WINDOW = 100
export const SAMPLES = 12
/** Flights are sampled ahead at this period; a compositor plays the table. */
export const FLIGHT_DT = 1000 / 60
export const DOUBLE_TOUCH = 300
export const DOUBLE_MOUSE = 500
export const DOUBLE_TRAVEL = 24
export const SETTLE_LIMIT = 2000
export const MAX_DT = 32
export const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v))
/** A media box is finite and positive on both axes; anything else is a lie. */
export function assertSize(size: Size): void {
assert(
Number.isFinite(size.w) && size.w > 0,
`width must be finite > 0, got ${size.w}`,
)
assert(
Number.isFinite(size.h) && size.h > 0,
`height must be finite > 0, got ${size.h}`,
)
}
/** Contain `natural` inside `band` less `inset` on every side, never upscaled past its
* pixels — EXCEPT to where the page already showed it. `floor` is the trigger's box: a
* 320 px gif that a post renders 1300 px wide has already been accepted at that scale,
* and a lightbox that opened it at 320 px shrank the thing it was asked to enlarge. The
* band still caps it, and a floor smaller than the pixels (a 40 px avatar of a 256 px
* portrait) changes nothing. */
export function fit(natural: Size, band: Band, inset = 0, floor?: Size): Size {
assertSize(natural)
const contain = Math.min(
(band.w - 2 * inset) / natural.w,
(band.h - 2 * inset) / natural.h,
)
const shown = floor ? Math.min(floor.w / natural.w, floor.h / natural.h) : 0
const k = Math.min(contain, Math.max(1, shown))
return { w: natural.w * k, h: natural.h * k }
}
export type SourceView = {
view: View
/** Local-px inset that turns the fit box into the trigger's cover crop. */
clip: Size
/** Local-px corner radius matching the trigger's. */
radius: number
}
/** The view that lays the fit box over the trigger's live rect, cover-fashion. `view`
* is relative to the band center; clip and radius are in the layer's own px (before
* scale). Identity when the aspects match: clip is zero. */
export function sourceView(rect: Rect, fitted: Size, band: Band): SourceView {
assert(rect.w > 0 && rect.h > 0, "trigger has a zero rect")
const cover = Math.max(rect.w / fitted.w, rect.h / fitted.h)
return {
view: {
x: rect.x + rect.w / 2 - (band.left + band.w / 2),
y: rect.y + rect.h / 2 - (band.top + band.h / 2),
s: cover,
},
clip: {
w: (fitted.w * cover - rect.w) / 2 / cover,
h: (fitted.h * cover - rect.h) / 2 / cover,
},
radius: rect.radius / cover,
}
}
/** Rescale so the point `at` (relative to the band center) stays under the finger. */
export function zoomAt(view: View, s: number, at: Point): View {
const k = s / view.s
return { x: at.x - (at.x - view.x) * k, y: at.y - (at.y - view.y) * k, s }
}
/** Zoom rubber: soft under `min`, stiff over `max`. */
export function rubber(raw: number, min: number, max: number): number {
if (raw < min) return min - 0.15 * (min - raw)
if (raw > max) return max + 0.05 * (raw - max)
return raw
}
/** Pan bound per axis: half the overflow of the scaled fit past the band. */
/** How far past its own edge a zoomed image may be pushed, so that panning to an edge
* leaves a margin instead of stopping dead against the viewport. The SAME on all four
* sides, so the whitespace reads as deliberate wherever the reader lands, and it is
* the reason an edge is reachable at all: stopping exactly where the picture ends
* gives no sign that it ended, only a picture that will not move. */
export const PAN_INSET = 64
export function panBounds(view: View, fitted: Size, band: Band): Point {
const x = (fitted.w * view.s - band.w) / 2
const y = (fitted.h * view.s - band.h) / 2
// An axis that does not overflow stays centred: there is no edge to reach, and an
// inset there would let a fitted image drift off centre.
return { x: x > 0 ? x + PAN_INSET : 0, y: y > 0 ? y + PAN_INSET : 0 }
}
/** A rubber band ASYMPTOTES. Resistance that is merely proportional (excess × 0.35)
* keeps giving forever, so a long trackpad pan walks the image hundreds of px off
* its own edge and the edge stops meaning anything. Past the bound the give shrinks
* toward nothing and the offset can never exceed OVERSHOOT_MAX, so pulling harder
* is felt as the edge pulling back. The slope at the bound is still OVERSHOOT, so
* the first px past it feels exactly as it did. */
export const OVERSHOOT_MAX = 96
const band_ = (excess: number) =>
(1 - 1 / ((excess * OVERSHOOT) / OVERSHOOT_MAX + 1)) * OVERSHOOT_MAX
export function overshoot(value: number, bound: number): number {
const excess = Math.abs(value) - bound
if (excess <= 0) return value
return Math.sign(value) * (bound + band_(excess))
}
/** The exact inverse: a grab taken past the bound (mid-bounce, or a rubbered wheel
* pan) is read back to the raw offset it rubbered from, so a drag that offsets the
* raw grab and rubbers the sum is continuous at dx = 0.
*
* `overshoot` maps every excess into [0, OVERSHOOT_MAX), so past that the value did
* NOT come from a band and there is nothing to undo: a dismiss pinch carries the
* picture wherever the fingers go, under bounds that mean nothing below fit. It IS
* the raw offset. Asserting instead threw out of `gestureDown`, which wedged every
* touch after it. */
export function unovershoot(value: number, bound: number): number {
const given = Math.abs(value) - bound
if (given <= 0 || given >= OVERSHOOT_MAX) return value
const excess = (given * OVERSHOOT_MAX) / (OVERSHOOT * (OVERSHOOT_MAX - given))
return Math.sign(value) * (bound + excess)
}
export function clampPan(view: View, fitted: Size, band: Band): View {
const b = panBounds(view, fitted, band)
return { x: clamp(view.x, -b.x, b.x), y: clamp(view.y, -b.y, b.y), s: view.s }
}
/** A grab's pan read back through the rubber: the raw offset a session adds its
* deltas to before rubbering the sum (see `unovershoot`). */
export function rawPan(view: View, fitted: Size, band: Band): Point {
const b = panBounds(view, fitted, band)
return { x: unovershoot(view.x, b.x), y: unovershoot(view.y, b.y) }
}
/** Where a flick comes to rest on its own. */
export function project(position: number, velocity: number): number {
return position + velocity * MOMENTUM
}
/** A wheel event's delta in px: lines and pages become px. Unbounded, so the inertia
* guard sees the tick the device sent. */
export function wheelPx(
delta: number,
deltaMode: number,
pageH: number,
): number {
const k = deltaMode === 1 ? 16 : deltaMode === 2 ? pageH : 1
return delta * k
}
/** One tick's motion: bounded, so a mouse notch is a nudge. */
export function wheelTick(px: number): number {
return clamp(px, -WHEEL_TICK_MAX, WHEEL_TICK_MAX)
}
/** Vertical dismiss drag at fit: the room re-lights over a third of the viewport. */
export function dragScale(y: number, vh: number): number {
return 1 - 0.2 * Math.min(1, Math.abs(y) / (vh / 3))
}
export function dragProgress(y: number, vh: number): number {
return 1 - Math.min(1, Math.abs(y) / (vh / 3))
}
/** Pinch under fit: the room is fully out at s = 1/1.2. */
export function pinchProgress(s: number): number {
return clamp(1 - (1 - s) * 1.2, 0, 1)
}
export function dismissCommit(y: number, vy: number, vh: number): boolean {
return Math.abs(project(y, vy)) / (vh / 3) > DISMISS_COMMIT
}
/** Which neighbours exist around `index`: every wrap rule in one place. */
export function neighbours(
index: number,
count: number,
loop: boolean,
): { prev: boolean; next: boolean } {
assert(index >= 0 && index < count, `index ${index} of ${count}`)
return { prev: loop || index > 0, next: loop || index < count - 1 }
}
export type Sample = Point & { t: number }
/** Mean velocity (px/ms) over the samples inside VELOCITY_WINDOW, measured against
* the release instant `now`. The release is NOT a sample: a mouse button lifts tens
* of ms after the hand's last motion, and a release point duplicating the last
* move would halve the speed. A hand that rested longer than STOP_GAP before `now`,
* or before its own last sample, has stopped and carries nothing. */
export function velocity(samples: readonly Sample[], now: number): Point {
const recent = samples.filter((s) => now - s.t <= VELOCITY_WINDOW)
if (recent.length < 2) return { x: 0, y: 0 }
const a = recent[0] as Sample
const b = recent[recent.length - 1] as Sample
const before = recent[recent.length - 2] as Sample
if (now - b.t > STOP_GAP || b.t - before.t > STOP_GAP) return { x: 0, y: 0 }
const dt = Math.max(1, b.t - a.t)
return { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt }
}
/** The zoom ceiling: never under 2, otherwise the source's native pixels at this DPR. */
export function zoomMax(naturalW: number, fitW: number, dpr: number): number {
return Math.max(2, naturalW / (fitW * dpr))
}
/** The scale at which the source runs out of pixels. Under 2 means source-limited. */
export function sharpScale(
naturalW: number,
fitW: number,
dpr: number,
): number {
return naturalW / (fitW * dpr)
}
/** A wheel session is a hand only if its first three ticks are not monotonically
* decaying in magnitude; inertia only ever decays. */
export function wheelIsHand(ticks: readonly number[]): boolean {
assert(ticks.length >= 3, "wheel guard needs three ticks")
const [a, b, c] = ticks as [number, number, number]
return !(a > b && b > c)
}
export type Obstruction = { side: "top" | "bottom"; edge: number }
/** The stage: the visual viewport minus declared chrome. `edge` is the obstruction's
* inner edge in viewport coordinates (a top bar's bottom, a bottom bar's top). */
export function stageBand(vv: Band, blocks: readonly Obstruction[]): Band {
let top = vv.top
let bottom = vv.top + vv.h
for (const b of blocks) {
if (b.side === "top") top = Math.max(top, b.edge)
else bottom = Math.min(bottom, b.edge)
}
assert(bottom > top, "obstructions cover the whole viewport")
return { top, left: vv.left, w: vv.w, h: bottom - top }
}
/** The position along a move, `s` from 0 to 1. A cubic Hermite between where the track
* is (moving at the speed it was handed) and where it belongs (at rest).
*
* This is the shape a magnet has and a spring does not: it is exactly home at s = 1,
* with no asymptote to crawl down. `m0` is the entry tangent, the speed at s = 0 in
* the same units as `d`, and it is what the move feels like leaving. Held between
* GLIDE_ENTRY_MIN and GLIDE_ENTRY it always goes at once and eases in; handed more
* than a swipe's own speed it simply continues at it, so the handover is invisible. */
export function glide(d: number, m0: number, s: number): number {
const s2 = s * s
const s3 = s2 * s
return (s3 - 2 * s2 + s) * m0 + (3 * s2 - 2 * s3) * d
}
export type Axes<K extends string> = Readonly<Record<K, number>>
const zero = <K extends string>(like: Axes<K>): Axes<K> =>
Object.fromEntries(Object.keys(like).map((k) => [k, 0])) as Axes<K>
const isTuning = (t: Tunings<string>): t is Tuning => "zeta" in t
const tuningOf = <K extends string>(t: Tunings<K>, k: K): Tuning =>
isTuning(t) ? t : t[k]
/** One step of a damped spring on every axis, in px and ms: the velocity unit is
* the one gestures measure and momentum projects. The closed-form solution of the
* oscillator, exact at any frame period, so a device holding 30 fps gets the same
* curve as one at 120. MAX_DT is a per-frame progress cap, not a stability limit.
* Pure. */
function springStep<K extends string>(
value: Axes<K>,
vel: Axes<K>,
target: Axes<K>,
tuning: Tunings<K>,
dtMs: number,
): { value: Axes<K>; vel: Axes<K> } {
const dt = Math.min(dtMs, MAX_DT)
const nv: Record<string, number> = {}
const nx: Record<string, number> = {}
for (const k of Object.keys(value) as K[]) {
const tu = tuningOf(tuning, k)
if (tu.f === Infinity) {
nx[k] = target[k]
nv[k] = 0
continue
}
assert(tu.zeta >= 0 && tu.zeta <= 1, `zeta ${tu.zeta} out of [0, 1]`)
const w = (2 * Math.PI * tu.f) / 1000
const z = tu.zeta
const e = Math.exp(-z * w * dt)
const d0 = value[k] - target[k]
const v0 = vel[k]
let d: number
let v: number
if (z === 1) {
const c = v0 + w * d0
d = (d0 + c * dt) * e
v = (v0 - c * w * dt) * e
} else {
const wd = w * Math.sqrt(1 - z * z)
const b = (v0 + z * w * d0) / wd
const cos = Math.cos(wd * dt)
const sin = Math.sin(wd * dt)
d = e * (d0 * cos + b * sin)
v = e * ((wd * b - z * w * d0) * cos - (wd * d0 + z * w * b) * sin)
}
nx[k] = target[k] + d
nv[k] = v
}
return { value: nx as Axes<K>, vel: nv as Axes<K> }
}
function settled<K extends string>(
value: Axes<K>,
vel: Axes<K>,
target: Axes<K>,
eps: Axes<K>,
): boolean {
for (const k of Object.keys(value) as K[]) {
if (Math.abs(value[k] - target[k]) >= eps[k]) return false
if (Math.abs(vel[k]) >= eps[k] * 0.02) return false
}
return true
}
/** The one clock. Holds the live value, a target and a tuning; `step` advances by a
* frame and answers whether it settled. A spring that has not settled within
* SETTLE_LIMIT ms screams: a stuck phase is a bug, never a wait. */
export class Spring<K extends string> {
value: Axes<K>
vel: Axes<K>
target: Axes<K>
tuning: Tunings<K> = MACHINE
/** Integrated time since the last aim, capped per frame the way the step is. */
elapsed = 0
readonly eps: Axes<K>
constructor(value: Axes<K>, eps: Axes<K>) {
this.value = value
this.vel = zero(value)
this.target = value
this.eps = eps
}
/** Retarget mid-flight; velocity carries over unless handed a new one. */
aim(target: Axes<K>, tuning: Tunings<K>, vel?: Axes<K>): void {
this.target = target
this.tuning = tuning
if (vel) this.vel = vel
this.elapsed = 0
}
/** Drop the clock at the current value (a hand took over). */
hold(): void {
this.target = this.value
this.vel = zero(this.value)
}
step(dtMs: number): boolean {
const next = springStep(
this.value,
this.vel,
this.target,
this.tuning,
dtMs,
)
this.value = next.value
this.vel = next.vel
this.elapsed += Math.min(dtMs, MAX_DT)
const done = settled(this.value, this.vel, this.target, this.eps)
if (done) {
this.value = this.target
this.vel = zero(this.value)
}
assert(
done || this.elapsed <= SETTLE_LIMIT,
`spring stuck: ${this.elapsed.toFixed(0)} ms without settling`,
)
return done
}
}
export type Frame<K extends string> = {
t: number
value: Axes<K>
vel: Axes<K>
}
/** The whole flight ahead of time: the spring is deterministic, so it is sampled at
* FLIGHT_DT until it settles and the table is handed to the compositor. Frame 0 is
* the start; the last frame is the target exactly. A stuck spring screams here. */
export function sampleFlight<K extends string>(
value: Axes<K>,
vel: Axes<K>,
target: Axes<K>,
tuning: Tunings<K>,
eps: Axes<K>,
): Frame<K>[] {
const s = new Spring<K>(value, eps)
s.aim(target, tuning, vel)
const frames: Frame<K>[] = [{ t: 0, value, vel }]
let t = 0
for (;;) {
const done = s.step(FLIGHT_DT)
t += FLIGHT_DT
frames.push({ t, value: s.value, vel: s.vel })
if (done) break
}
return frames
}
/** The pose and velocity at `t` ms into a sampled flight, interpolated between
* frames; clamped to the table's ends. */
/** WebKit keeps animation time in seconds and converts back to milliseconds, so a
* finished animation's currentTime comes back a hair under the duration it was
* given (583.33333333333314 for 583.33333333333337). Times within this of the last
* frame ARE the last frame. */
export const TIME_EPS = 1e-3
export function frameAt<K extends string>(
frames: readonly Frame<K>[],
t: number,
): Frame<K> {
assert(frames.length >= 2, "a flight has at least two frames")
assert(Number.isFinite(t), `a flight time that is not a number: ${t}`)
const last = frames[frames.length - 1] as Frame<K>
if (t >= last.t - TIME_EPS) return last
if (t <= 0) return frames[0] as Frame<K>
const i = Math.min(frames.length - 2, Math.floor(t / FLIGHT_DT))
const a = frames[i] as Frame<K>
const b = frames[i + 1] as Frame<K>
const k = (t - a.t) / (b.t - a.t)
const lerp = (p: Axes<K>, q: Axes<K>): Axes<K> =>
Object.fromEntries(
Object.keys(p).map((key) => [
key,
p[key as K] + (q[key as K] - p[key as K]) * k,
]),
) as Axes<K>
return { t, value: lerp(a.value, b.value), vel: lerp(a.vel, b.vel) }
}// THE SLIDE TRACK: a row of slides in a real scroll container, one filling it at a
// time, driven by this engine for every input. What the lightbox swipes its pictures
// on and what tabs swipe their panels on: ONE engine, so the two cannot feel apart.
// Its rules, and why each is so, are the lightbox's (`lightbox.notes.md`, "The slide
// track"; the numbers in `lib/lightbox-motion`):
//
// - The track has ONE writer and two terms: `scrollLeft = base + offset`, clamped
// to the first and the last slide. `base` is where the machine has it (a slide at
// rest, or a glide in flight); `offset` where the hand has the slides from the
// slide it is anchored to. A commit moves the anchor and resets the offset, and
// their sum does not move a pixel.
// - A slide TIPS past its halfway point (`SWIPE_TIP`): the outcome is on the
// screen before it happens. A finger holds the slides 1:1, never torn away, and
// they go where it leaves them; a wheel moves them by `swipeGain` (its hand's
// price for a slide carries one to its tip) and they tip the moment they pass it.
// - A gesture that ends lands where its motion would have coasted (`swipeTip`):
// on a pointer's release, on a wheel's lift (its pace falling under `SWIPE_LIFT`
// of its own peak: a coast only slows), or on `SWIPE_END` of silence.
// - One slide a gesture; decided, the gesture is spent and what the device still
// sends moves nothing, until a surge, a turn (`swipeBreak`), the wheel phase's
// `handBack` or the pace climbing again says the hand is back.
// - Every move after a decision is a Hermite glide (`glide`) from where the slides
// STAND at the speed they were moving: nothing jumps in speed.
//
// The scroller wears `SLIDE_TRACK` (or the same rules in a stylesheet): mandatory
// snapping lands what the engine leaves, and `data-stepping` stands it down while the
// engine drives. Each slide wears `SLIDE`. No React: the binder (a lightbox, tabs)
// feeds it and is told where it is heading and where it arrived.
import {
carryMs,
clamp,
GLIDE,
GLIDE_ENTRY,
GLIDE_ENTRY_MIN,
GLIDE_MAX,
GLIDE_MIN,
glide as glideCurve,
SAMPLES,
type Sample,
SWIPE_END,
SWIPE_LIFT,
SWIPE_LIFT_EVENTS,
SWIPE_TIP,
swipeAgain,
swipeBreak,
swipeEnvelope,
swipeGain,
swipePace,
swipeTip,
velocity,
} from "@/registry/base-nova/lib/lightbox-motion"
/** The scroller's own rules, as utilities: a row that scrolls sideways under no
* scrollbar, snapping each slide to its centre and stopping on every one, the
* snapping stood down while the engine drives (`data-stepping`). A hand never pans
* it (`touch-action`): the engine moves it, for every pointer. */
export const SLIDE_TRACK =
"flex overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [scroll-snap-type:x_mandatory] data-[stepping]:[scroll-snap-type:none]"
/** A slide: the track's whole width, snapped at its centre, never skipped. */
export const SLIDE =
"w-full shrink-0 [scroll-snap-align:center] [scroll-snap-stop:always]"
export interface SlideTrackOptions {
/** The scroller. */
el: HTMLElement
/** How many slides it holds now. */
count: () => number
/** The slide the binder calls current. */
current: () => number
/** Under reduced motion a glide is a cut. */
reduced: () => boolean
/** Where the track is heading changed: a slide bought or stepped to, the chrome
* answering the decision rather than the arrival; null, it is on the current. */
onAim: (i: number | null) => void
/** The track arrived on a slide other than the current one. */
onArrive: (i: number) => void
/** A decision, for the binder's trace (`swipe → 3`). */
trace?: (line: string) => void
/** A gesture's whole trajectory as a table, once it ends (debug only). */
report?: (table: string) => void
/** Whether the trajectory recorder runs. */
debug?: () => boolean
}
export interface SlideTrack {
/** One wheel event's sideways travel, px, and what the wheel phase
* (`lib/lightbox-wheel-phase`) read: `handBack`, it opened a stream since the
* last event fed here (its `start`, held by the binder over the events it kept
* back), and `coasting`, its `momentum`. */
wheel(dx: number, handBack: boolean, coasting: boolean): void
/** A finger's sideways travel, px, the slides' way (a finger moving right brings
* the slide from the left: pass its negative). */
finger(dx: number): void
/** The hand let go: the slides land where its motion would have coasted. */
release(): void
/** Another hand takes over (a pointer coming down, a key): the swipe ends and a
* glide stops where it is. */
drop(): void
/** Glide to slide `i`, chaining on a glide in flight. */
glideTo(i: number): void
/** Put slide `i` under the reader at once (a wrap, a resize). */
jumpTo(i: number): void
/** Re-measure the slide's pitch: the scroller changed size. */
measure(): void
/** The slide nearest where the track stands. */
landed(): number
/** Where the track is heading, if anywhere but the current slide. */
aim(): number | null
/** Set where it is heading (a step decided elsewhere). */
aimAt(i: number | null): void
/** A hand is on it, or the engine is moving it: the browser's own settling must
* not be read as a choice. */
busy(): boolean
/** Stop a glide where it stands, the swipe untouched (the binder leaving). */
stop(): void
/** The browser brought it to rest (`scrollsnapchange`, `scrollend`): the slide
* under the reader is current, unless a hand or a glide of ours is moving it. */
settle(): void
dispose(): void
}
export function createSlideTrack(o: SlideTrackOptions): SlideTrack {
const { el } = o
const trace = (text: string) => o.trace?.(text)
const debugging = () => o.debug?.() ?? false
// ---- THE INSTRUMENT (debug only). Every decision is one line, `track <what>`,
// with the engine's whole state after it: the anchor (a), the hand's travel this
// gesture (h, px of trackpad or finger), the slides' offset from the anchor it
// makes (o: h by the wheel's gain, or 1:1), where the machine has the track (b),
// what was last
// WRITTEN to the scroller (w), and the flags (step: snapping held off, swipe: a
// hand on it, done: the gesture spent, glide: a glide running, aim). A decision
// line never reads the scroller (a read after a write forces a layout, and the
// instrument becomes the thing it measures). The screen's truth is read ONCE a
// frame by the watchdog, which says, the moment it happens:
// MOVED the scroller is not where the engine last wrote it (the
// browser moved it: a snap, a pan, a resize)
// STUCK STEPPING the snapping held off while nothing of ours drives the track
// OFF A SLIDE the track at rest between two slides
let written = Number.NaN
const r = (n: number) => (Number.isFinite(n) ? Math.round(n) : "-")
const flags = () =>
`${el.dataset.stepping !== undefined ? " step" : ""}${swipe ? " swipe" : ""}${swipeDone ? " done" : ""}${glide ? " glide" : ""} aim ${aiming ?? "-"}`
const say = (what: string) => {
if (!debugging()) return
trace(
`track ${what} · a ${swipeAnchor} h ${r(swipeHand)} o ${r(give())} b ${r(base)} w ${r(written)}${flags()}`,
)
}
const stepOn = (why: string) => {
if (el.dataset.stepping === undefined) say(`snap off (${why})`)
el.dataset.stepping = ""
}
const stepOff = (why: string) => {
if (el.dataset.stepping !== undefined) {
delete el.dataset.stepping
say(`snap on (${why})`)
}
}
let watch = 0
let idleSince = 0
const watchdog = () => {
watch = requestAnimationFrame(watchdog)
if (!debugging()) return
const at = el.scrollLeft
if (Number.isFinite(written) && Math.abs(at - written) > 1) {
trace(
`track MOVED · wrote ${r(written)}, reads ${r(at)} (${r(at - written)} px, slide ${(at / slot).toFixed(2)})${flags()}`,
)
written = at
}
const busy = swipe || glide !== 0
if (busy) {
idleSince = 0
return
}
const now = performance.now()
if (!idleSince) idleSince = now
if (now - idleSince < 400) return
if (el.dataset.stepping !== undefined) {
trace(`track STUCK STEPPING · at ${r(at)}${flags()}`)
idleSince = now + 2000
}
const off = at / slot - Math.round(at / slot)
if (Math.abs(off * slot) > 1) {
trace(
`track OFF A SLIDE · at ${r(at)}, slide ${(at / slot).toFixed(3)}${flags()}`,
)
idleSince = now + 2000
}
}
/** A slide's pitch, CACHED: a layout read, asked several times a wheel event,
* each one between `scrollLeft` writes. Uncached, wheel events arrived 33 ms
* apart carrying 189 px each while the page reflowed. The room between two
* slides is the track's own `column-gap`, read here, never a number kept beside
* it: one in rem drifted the track off every slide on a page whose rem is not
* 16 px. The width is the LAYOUT's, to the fraction (the computed `width`):
* `clientWidth` rounds (a track 536.5 px wide measured 536 put the sixth slide
* 2.5 px off its snap point), and `getBoundingClientRect` is the width as DRAWN,
* transforms included, so a stage measured while it scaled in would carry a
* wrong pitch for the whole session. */
let slot = 0
const measure = () => {
const style = getComputedStyle(el)
const gap = Number.parseFloat(style.columnGap)
slot = Number.parseFloat(style.width) + (Number.isFinite(gap) ? gap : 0)
if (!(slot > 0)) throw new Error(`slide-track: a pitch of ${slot}`)
if (debugging())
trace(
`track measure · pitch ${slot.toFixed(2)} (width ${style.width}, gap ${style.columnGap}, drawn ${el.getBoundingClientRect().width.toFixed(2)}, client ${el.clientWidth})`,
)
}
const slotW = () => slot
const landed = () =>
Math.min(o.count() - 1, Math.max(0, Math.round(el.scrollLeft / slotW())))
let aiming: number | null = null
const aimAt = (i: number | null) => {
if (aiming !== i) say(`aim ${i ?? "-"}`)
aiming = i
o.onAim(i)
}
const arrive = (i: number) => {
say(`arrive ${i} (current ${o.current()})`)
if (i === o.current()) {
// Back on the slide it already called current: whatever it was heading for is
// over, and the next step counts from here.
aimAt(null)
return
}
aimAt(i)
o.onArrive(i)
}
let base = 0
let swipeTravel = 0
let glide = 0
let swipe = false
let swipeAnchor = 0
let swipeHand = 0
let swipeDone = false
let swipeEnv = 0
let swipeDir = 0
let swipeTimer = 0
let swipeWhy = ""
/** Where the hand has the slides, px from their anchor (`swipeTravel`, already
* held to the slides there are). Decided, nothing: the glide has them. */
const give = () => (swipeDone ? 0 : swipeTravel)
const paint = () => {
written = clamp(base + give(), 0, (o.count() - 1) * slotW())
el.scrollLeft = written
}
/** Hand the track back to the machine where it stands. */
const rebase = () => {
base = el.scrollLeft
swipeTravel = 0
}
const stopGlide = (why: string) => {
if (!glide) return
cancelAnimationFrame(glide)
glide = 0
say(`glide cut (${why})`)
// Cut short between two slides: the magnets come back and the browser takes it
// to the nearest; not while a hand is on the track.
if (!swipe) stepOff("glide cut")
}
// ---- the trajectory recorder (debug only). What the hand asked for against what
// the slides actually did, sample by sample, so "it feels slow" becomes a number.
// It records NUMBERS ONLY while the gesture runs and formats once at the end: an
// instrument that reads the DOM or touches React per event becomes the thing it is
// measuring (the lightbox was once slowed to a crawl by a trace that called
// getComputedStyle on every wheel event).
type Shot = { t: number; dx: number; sum: number; at: number }
let shots: Shot[] = []
let shotT0 = 0
let shotFrom = 0
let shotWhy = ""
/** Wheel arrivals only. A glide frame shares a timestamp with the event that
* started it, and those zero gaps dragged the median to 1 ms and printed
* "1000Hz" over a stream running at thirty. */
let shotEvents: number[] = []
/** Frames the PAGE drew, against the events it was fed. Equal and low is the
* renderer over budget (a 165 ms move gets five frames, the jag); events
* slower than frames is the device. */
let shotFrames = 0
let shotRaf = 0
const shotTick = () => {
shotFrames++
shotRaf = requestAnimationFrame(shotTick)
}
/** `at` is the value JUST WRITTEN, never read back off the element: reading
* `scrollLeft` after writing it forces a synchronous layout, once per event. */
const shoot = (dx: number, sum: number, at: number, wheel = true) => {
if (!o.debug?.()) return
const now = performance.now()
if (!shots.length) {
shotT0 = now
shotFrom = swipeAnchor
shotWhy = swipeWhy
shotFrames = 0
shotEvents = []
shotRaf = requestAnimationFrame(shotTick)
}
const t = Math.round(now - shotT0)
if (wheel) shotEvents.push(t)
shots.push({ t, dx: Math.round(dx), sum, at })
}
const shootGlide = () => {
if (!o.debug?.() || !shots.length) return
shoot(0, (shots[shots.length - 1] as Shot).sum, base + give(), false)
}
/** How many rows a table gets: enough to see a shape, few enough to paste. */
const TRACE_ROWS = 16
/** One gesture, compressed: its header the verdict and the input stream's health,
* its table evenly downsampled, the shape of the two columns being the point.
* A wheel arrives once a display frame, so the median gap is the REFRESH RATE
* (33 ms is a 30 Hz screen, not a fault); a stall is the spread, read at the
* 90th percentile, not the worst (at 125 Hz one 32 ms gap in sixty is a hiccup).
* The slides walking BACKWARDS against the hand (`back`, the worst retreat in
* slides) is a snap-back under a finger still moving. */
const report = (why: string) => {
cancelAnimationFrame(shotRaf)
shotRaf = 0
if (!o.debug?.() || shots.length < 2) {
shots = []
return
}
const w = slotW()
const last = shots[shots.length - 1] as Shot
const first = shots[0] as Shot
const hand = Math.abs(last.sum) / w
const moved = (last.at - first.at) / w
const gaps = shotEvents
.slice(1)
.map((t, i) => t - (shotEvents[i] as number))
.sort((a, b) => a - b)
const gapMs = Math.max(1, (gaps[gaps.length >> 1] ?? 0) as number)
const worst = (gaps[Math.floor(gaps.length * 0.9)] ?? 0) as number
const rate = `@${gapMs}ms ${Math.round(1000 / gapMs)}Hz${
worst > 2.5 * gapMs ? ` JANK ${worst}ms` : ""
}`
const maxDx = Math.max(...shots.map((s) => Math.abs(s.dx)))
let back = 0
for (let i = 1; i < shots.length; i++) {
const d = ((shots[i] as Shot).at - (shots[i - 1] as Shot).at) / w
if (Math.sign(d) === -Math.sign(last.sum)) back = Math.max(back, -d)
}
const step = Math.max(1, Math.ceil(shots.length / TRACE_ROWS))
const rows = shots
.filter((_, i) => i % step === 0 || i === shots.length - 1)
.map(
(s) =>
`${String(s.t).padStart(5)}${String(s.dx).padStart(6)}${((s.sum / w) * 100).toFixed(0).padStart(6)}${(((s.at - first.at) / w) * 100).toFixed(0).padStart(6)}`,
)
o.report?.(
[
`── ${shotFrom}→${swipeAnchor} (${shotWhy}) · ${last.t}ms · ${shots.length}ev ${rate} · ${Math.round((shotFrames * 1000) / Math.max(1, last.t))}fps · maxdx ${maxDx}`,
` hand ${hand.toFixed(2)} · pics ${moved.toFixed(2)} · ${(Math.abs(last.sum) / Math.max(1, last.t)).toFixed(2)}px/ms · back ${back.toFixed(2)} · ${why}`,
` ms dx hand%track% (% of a slide, from the start)`,
...rows,
].join("\n"),
)
shots = []
}
/** The track arrives at slide `i`, from where the slides stand. A machine's move
* (a key, a tab pressed) leaves briskly (`GLIDE_ENTRY_MIN`); a hand's (`carry`,
* the slides' own speed `vx` px a ms) leaves at EXACTLY that speed, whichever way
* it goes, so a decision is never a jump in speed: measured in the gym, the floor
* launched slides moving 0.5 px/ms at 17 px/ms, "so disconnected". */
const glideTo = (i: number, vx = 0, carry = false) => {
stopGlide("a new glide")
// MAGNETS DOWN BEFORE ANYTHING READS THE SCROLLER: a mandatory container
// re-snaps at the next layout, and reading `scrollLeft` forces it.
stepOn("glide")
// Derived from the ground truth every time: the browser moves this scroller
// too, and a remembered `base` would be stale.
base = el.scrollLeft - give()
const to = i * slotW()
const from = base
const d = to - from
if (o.reduced() || Math.abs(d) < 0.5) {
say(`glide ${i}: home already (${r(d)} px)`)
base = to
paint()
// Home already, or a cut: the magnets come back here too (the lightbox's
// own gesture end did it for it; a track nothing else tends must not be
// left unsnapped).
if (!swipe) stepOff("home already")
arrive(i)
return
}
// A key's: far to go, longer to go it, bounded, never linear in the distance.
// A hand's: as long as the way takes at the hand's own speed (`carryMs`).
const ms = carry
? carryMs(d, vx, slotW())
: clamp(GLIDE * Math.sqrt(Math.abs(d) / slotW()), GLIDE_MIN, GLIDE_MAX)
const m0 =
d *
clamp((vx * ms) / d, carry ? -GLIDE_ENTRY : GLIDE_ENTRY_MIN, GLIDE_ENTRY)
say(
`glide ${i}: ${r(from)} → ${r(to)} in ${Math.round(ms)} ms, leaving at ${(m0 / ms).toFixed(2)} px/ms (hand ${vx.toFixed(2)})`,
)
const t0 = performance.now()
const step = (t: number) => {
const s = Math.min(1, (t - t0) / ms)
base = from + glideCurve(d, m0, s)
paint()
shootGlide()
if (s < 1) {
glide = requestAnimationFrame(step)
return
}
// Exactly home, on a snap point, so handing the magnets back is invisible.
glide = 0
base = to
paint()
shootGlide()
say(`glide ${i}: landed`)
if (!swipe) stepOff("landed")
arrive(i)
if (!swipe) report(`landed on ${i}`)
}
glide = requestAnimationFrame(step)
}
/** The slides' own speed now, px a ms, off their timed offsets: a hand held
* still before it ended reads as stopped (`velocity`). */
const speed = () => velocity(trail, performance.now()).x
/** Decided: on to the slide `step` away (1, -1) or back to the anchor (0), from
* where the slides stand at the speed they had, and the gesture is spent, so
* what the device still sends moves nothing. */
const settleOn = (step: number, why: string) => {
const v = speed()
const pct = `${Math.round((swipeTravel / slotW()) * 100)}% at ${v.toFixed(2)} px/ms`
swipeDone = true
swipeDir = step || Math.sign(swipeTravel)
// Watching for the hand again starts once this push has lifted: a slide that
// tipped under a hand still speeding up must not read the rest of that one
// push as a second (two slides a gesture, the slot machine).
// A lift is a push already lifted, so it watches at once.
spentPeak = strokePeak
spentLow = Number.POSITIVE_INFINITY
const now = swipePace(strokes)
if (now !== null) spentPace(now)
rebase()
if (!step) {
say(`back (${why}): ${pct}`)
glideTo(swipeAnchor, v, true)
return
}
swipeAnchor += step
// The chrome moves with the decision, not the arrival: the counter (a tab) is
// the answer to the gesture, and a stream opening on top anchors here.
aimAt(swipeAnchor)
say(`tip to ${swipeAnchor} (${why}): ${pct}`)
glideTo(swipeAnchor, v, true)
}
/** The hand is gone: the slides land where its motion would have coasted. */
const decide = (why: string) => {
const step = swipeTip(swipeTravel, speed(), slotW())
const to = swipeAnchor + step
settleOn(to >= 0 && to < o.count() ? step : 0, why)
}
/** A gesture takes the track, anchored where it is HEADING, so a swipe onto a
* glide in flight counts from the slide it is going to. */
const open = (why: string) => {
// The last gesture's glide may still run: read it out now, or its rows and this
// one's share a table.
report("cut short by the next")
// Held apart from the recording's copy (`shotWhy`): the next gesture's reason
// once overwrote the one still being reported.
swipeWhy = why
swipe = true
stopGlide("a hand took over")
stepOn("a hand")
swipeDone = false
swipeDir = 0
swipeHand = 0
swipeAnchor = aiming ?? landed()
rebase()
spentDropped = 0
settleIgnored = false
strokes = []
strokePeak = 0
spentPeak = 0
spentLow = Number.POSITIVE_INFINITY
trail = []
say(`open (${why})`)
}
/** Deltas a spent gesture swallowed, said once when it ends. */
let spentDropped = 0
let settleIgnored = false
/** The slides' offset, timed: their speed when a decision takes them over. */
let trail: Sample[] = []
/** The wheel gesture's last deltas, the fastest pace it has had, and, once it
* is decided (lifted, tipped, anything), the slowest since: what tells the
* hand lifting (`SWIPE_LIFT`) and the hand coming back after it (`swipeAgain`:
* a coast never speeds up). */
let strokes: number[] = []
let strokePeak = 0
/** Decided: the fastest the pace has been since, and its lowest once that push
* lifted (infinite before: not watching yet). */
let spentPeak = 0
let spentLow = Number.POSITIVE_INFINITY
const spentPace = (pace: number) => {
spentPeak = Math.max(spentPeak, pace)
if (spentLow < Number.POSITIVE_INFINITY || pace < SWIPE_LIFT * spentPeak)
spentLow = Math.min(spentLow, pace)
}
/** The lift needs BOTH the pace fallen under half its peak and the phase seeing
* the device coast (`coasting`, its even decay). The pace alone read a slow hand
* wobbling at 2-3 px an event (3, 3, 2, 2, 1) as one lifting, and the slides fell
* back from under a finger still down (the gym, 2026-10-15). The two mistakes are
* not equal: a lift called early tears the slides from the hand; one called late
* lets them follow the device's coast a moment, as any scroll does, until the
* silence. A slow hand barely coasts, so it ends on the silence. */
const lift = (coasting: boolean) => {
const pace = swipePace(strokes)
if (pace === null) return
strokePeak = Math.max(strokePeak, pace)
if (!coasting || pace >= SWIPE_LIFT * strokePeak) return
decide(
`lifted: ${pace.toFixed(1)} of a peak ${strokePeak.toFixed(1)} px an event, coasting`,
)
}
/** Travel, from whatever pushes, `gain` px of slide a px of hand: one rule for a
* wheel and a finger. The offset is held to the slides there are (nothing is
* behind the first or the last, so leaning toward it shows nothing and costs
* nothing to come back from) and to one slide either way. */
const feed = (dx: number, gain: number) => {
swipeHand += dx
// SPENT: the slide is decided, a glide has the slides, and what still arrives
// moves nothing. Buying another on the same gesture is the slot machine.
if (swipeDone) {
spentDropped += Math.abs(dx)
shoot(dx, swipeHand, base)
return
}
const w = slotW()
swipeTravel = clamp(
swipeTravel + dx * gain,
swipeAnchor > 0 ? -w : 0,
swipeAnchor < o.count() - 1 ? w : 0,
)
trail = [...trail, { x: swipeTravel, y: 0, t: performance.now() }].slice(
-SAMPLES,
)
paint()
shoot(dx, swipeHand, base + give())
}
const end = (why: string) => {
if (!swipe) return
say(
`end (${why})${spentDropped ? `, ${Math.round(spentDropped)} px of tail swallowed` : ""}`,
)
swipe = false
clearTimeout(swipeTimer)
if (!swipeDone) decide(why)
else if (!glide) {
// Spent and at rest: put it exactly on its slide and hand the magnets back.
// A glide still running is left to land (re-aiming it here, at no speed,
// stalled it under a finger that had just let go).
rebase()
glideTo(swipeAnchor, 0)
}
if (!glide) report(`ended on ${swipeAnchor}`)
}
measure()
watch = requestAnimationFrame(watchdog)
const silence = () => end("silence")
return {
wheel(dx, handBack, coasting) {
const pace = swipePace([...strokes, dx].slice(-SWIPE_LIFT_EVENTS))
// Only a SPENT gesture can be interrupted: one still choosing owns every delta.
const broke = swipeDone
? handBack
? "phase"
: pace !== null && swipeAgain(pace, spentLow)
? `the pace again (${pace.toFixed(1)}, from a low of ${spentLow.toFixed(1)} px an event)`
: swipeBreak(dx, swipeEnv, swipeDir)
: null
swipeEnv = swipeEnvelope(dx, swipeEnv)
if (swipe && broke) end(`a hand back: ${broke}`)
if (!swipe) {
open(broke ?? "new stream")
swipeEnv = Math.abs(dx)
}
strokes = [...strokes, dx].slice(-SWIPE_LIFT_EVENTS)
// Silence ends a wheel: it never says.
clearTimeout(swipeTimer)
swipeTimer = window.setTimeout(silence, SWIPE_END)
feed(dx, swipeGain(slotW()))
// Past its halfway point a slide tips, the hand still on the glass: a
// trackpad is not the picture, so nothing is torn from under the fingers.
if (swipe && !swipeDone && Math.abs(swipeTravel) >= SWIPE_TIP * slotW())
settleOn(Math.sign(swipeTravel), "past its tip")
else if (swipe && !swipeDone) lift(coasting)
else if (swipeDone && pace !== null) spentPace(pace)
},
finger(dx) {
if (!swipe) open("finger")
// 1:1: the picture is under the finger, and stays there until it lifts.
feed(dx, 1)
},
release: () => end("released"),
drop() {
end("dropped for another hand")
stopGlide("dropped for another hand")
},
glideTo: (i) => glideTo(i),
jumpTo(i) {
stopGlide("a jump")
el.scrollTo({ left: i * slotW(), behavior: "instant" })
base = el.scrollLeft
written = base
say(`jump ${i}`)
},
measure,
landed,
aim: () => aiming,
aimAt,
busy: () => swipe || glide !== 0,
stop: () => stopGlide("stopped"),
settle() {
// The browser carried it to rest on a snap point: that slide is current.
// Nothing of ours may be moving it.
if (swipe || glide) {
// Every write of ours raises one: said once a gesture, not once a frame.
if (!settleIgnored) say("browser settled, ignored: ours is moving it")
settleIgnored = true
return
}
const i = landed()
say(`browser settled on ${i}`)
arrive(i)
},
dispose() {
stopGlide("disposed")
clearTimeout(swipeTimer)
cancelAnimationFrame(shotRaf)
cancelAnimationFrame(watch)
},
}
}// A flight: a spring sampled into a frame table, played on the compositor as a Web
// Animation and read back through that animation's own clock. The binder owns the
// DOM (it creates the animations and cancels them); this module owns the table, the
// clock read and the landing rule. Framework-free: the clock is anything with a
// `currentTime`. `bun scripts/examples/lightbox-flight.ts` exercises it.
import {
type Axes,
assert,
type Frame,
frameAt,
sampleFlight,
TIME_EPS,
type Tunings,
} from "@/registry/base-nova/lib/lightbox-motion"
/** What a Web Animation looks like from here: a clock in ms. */
type Clock = { readonly currentTime: unknown }
export type Flight<K extends string> = {
frames: Frame<K>[]
target: Axes<K>
/** Reaching `target` is the pending checkpoint (settle the enter, close, clear
* pending); a flight that merely returns the image to a grab is not. */
settles: boolean
clock: Clock
}
/** The table and its duration: the two things the animation is built from. */
export function planFlight<K extends string>(
value: Axes<K>,
vel: Axes<K>,
target: Axes<K>,
tuning: Tunings<K>,
eps: Axes<K>,
): { frames: Frame<K>[]; duration: number } {
const frames = sampleFlight(value, vel, target, tuning, eps)
return { frames, duration: lastFrame(frames).t }
}
const lastFrame = <K extends string>(frames: readonly Frame<K>[]): Frame<K> => {
assert(frames.length >= 2, "a flight has at least two frames")
return frames[frames.length - 1] as Frame<K>
}
/** The animation's clock in ms. Screams on anything that is not a finite number: a
* null clock is a flight whose animation was never started or already cancelled. */
export function flightTime<K extends string>(f: Flight<K>): number {
const t = f.clock.currentTime
assert(
typeof t === "number" && Number.isFinite(t),
`flight without a clock: ${String(t)}`,
)
return t
}
/** Landed: the clock reached the last frame. WebKit hands the duration back a hair
* short (seconds in, milliseconds out), so "reached" is within TIME_EPS. */
export function flightDone<K extends string>(
f: Flight<K>,
now: number,
): boolean {
return now >= lastFrame(f.frames).t - TIME_EPS
}
/** The frame under the clock (pose and velocity, for a takeover or a tick) and
* whether the flight has landed. */
export function readFlight<K extends string>(
f: Flight<K>,
): { frame: Frame<K>; done: boolean } {
const now = flightTime(f)
return { frame: frameAt(f.frames, now), done: flightDone(f, now) }
}// The hold loop: a key held while zoomed drives the image at a constant rate until
// keyup. Arrows pan, every held arrow adding its axis, so up + left is a diagonal;
// + and - zoom about the center, doubling every KEY_ZOOM_MS. One glide, not a spring
// restarted on every repeat. Framework-free: the binder owns the rAF loop and the
// key tracking, this module turns the set of held verbs and a frame's dt into a
// view. `bun scripts/examples/lightbox-hold.ts` exercises it.
import {
type Band,
clampPan,
KEY_PAN_SPEED,
KEY_ZOOM_MS,
MAX_DT,
type Size,
type View,
zoomAt,
} from "@/registry/base-nova/lib/lightbox-motion"
/** The action ids a key can hold; a subset of the action table's ids. */
export type HoldVerb =
| "pan.left"
| "pan.right"
| "pan.up"
| "pan.down"
| "zoom.in"
| "zoom.out"
export type HoldDelta = {
/** Pan in px, image-space: left pushes the image right. */
dx: number
dy: number
/** Zoom factor; 1 when no zoom key is held. */
k: number
}
/** What one frame of `dt` ms adds for the verbs held. Axes add; opposite arrows
* cancel. dt is capped at MAX_DT so a frame lost to a tab switch never slingshots. */
export function holdDelta(held: Iterable<HoldVerb>, dt: number): HoldDelta {
const ms = Math.min(dt, MAX_DT)
let dx = 0
let dy = 0
let k = 1
for (const id of held) {
switch (id) {
case "pan.left":
dx += KEY_PAN_SPEED * ms
break
case "pan.right":
dx -= KEY_PAN_SPEED * ms
break
case "pan.up":
dy += KEY_PAN_SPEED * ms
break
case "pan.down":
dy -= KEY_PAN_SPEED * ms
break
case "zoom.in":
k *= 2 ** (ms / KEY_ZOOM_MS)
break
case "zoom.out":
k /= 2 ** (ms / KEY_ZOOM_MS)
break
default: {
const never: never = id
throw new Error(`lightbox: not a hold verb ${String(never)}`)
}
}
}
return { dx, dy, k }
}
/** The view after one frame of hold: the zoom is applied about the center and held
* between 1 and `zoomMax`, the pan is added, and the result is clamped to the
* band so a held arrow stops at the edge instead of rubbering. */
export function applyHold(
view: View,
delta: HoldDelta,
fitted: Size,
band: Band,
zoomMax: number,
): View {
const s = Math.min(zoomMax, Math.max(1, view.s * delta.k))
const zoomed = delta.k === 1 ? view : zoomAt(view, s, { x: 0, y: 0 })
return clampPan(
{ ...zoomed, x: zoomed.x + delta.dx, y: zoomed.y + delta.dy },
fitted,
band,
)
}// Which part of a wheel stream is the HAND and which is the device coasting.
//
// The web never says when fingers leave a trackpad. Every carousel that accumulates
// wheel deltas past a threshold gets this wrong the same way: momentum keeps filling
// the accumulator long after the reader meant to stop, so one flick pages four times.
// The signal that does exist is in the shape of the decay. Inertia is generated by a
// physics model, so each velocity is a near-constant fraction of the one before it;
// a hand is never that regular. Five merged points whose acceleration factor all sit
// in [ACC_MIN, ACC_MAX] (or which decay at EXACTLY the same rate on both axes, which
// only a model does) are the device, not a person.
//
// Vendored from wheel-gestures by Felix Leupold (xiel), MIT, the detector behind
// Embla's wheel plugin: github.com/xiel/wheel-gestures. Reduced to two axes and one
// pure function, our constants kept at his measured values. Numbers in, phase out.
// `bun scripts/examples/lightbox-wheel-phase.ts`.
import { type Point, SWIPE_END } from "@/registry/base-nova/lib/lightbox-motion"
/** A decaying velocity ratio inside this band is a physics model coasting. Outside
* it, something is pushing. */
export const ACC_MIN = 0.6
export const ACC_MAX = 0.96
/** Events are averaged in pairs before velocity: one raw wheel event is too noisy to
* take a ratio of. */
export const MERGE = 2
/** How many consecutive merged points must agree before we believe it is momentum.
* Every one of them is two wheel events, so this is the LATENCY of knowing the hand
* has let go, and that latency is felt directly: until it fires the device is still
* coasting and the reader cannot tell where they will land. Three, not five. A hand
* that decelerates evenly enough to be mistaken for a model over six events was
* stopping anyway, and if it pushes again `interrupted` hands the stream straight
* back. */
export const ANALYZE = 3
/** No single event moves more than this; a device reporting more is lying. */
export const DELTA_MAX = 700
/** Before the first two events time the stream, assume a slow mouse: a long window,
* so an old device emitting rarely still reads as one continuous gesture. */
export const ENDS_IN_DEFAULT = 400
export const ENDS_IN_MAX = 1000
/** Momentum is cut short by a delta this many times the last one: the hand is back. */
export const HAND_RETURN = 2
export type Wheel = { dx: number; dy: number; t: number }
export type Phase = {
live: boolean
opened: boolean
momentum: boolean
lastAbs: number
lastT: number
movement: Point
velocity: Point
accelerations: readonly Point[]
points: readonly { sum: Point; t: number }[]
merging: readonly Wheel[]
endsIn: number
}
/** A COAST NEVER CHANGES AXIS: the device carries on along the line of the swipe
* that made it. So inside a stream already moving one way (`AXIS_HELD` px on its
* axis), a delta of at least `AXIS_TURN` px that is mostly on the OTHER axis (twice
* its share of this one) is a hand starting a new gesture, and opens a stream.
* Measured in the swipe gym (2026-10-15): a swipe down to dismiss right after a
* sideways swipe waited for the sideways coast to be cut short by size alone, and
* went to the sideways track, spent, until then. A hand's diagonal jitter inside a
* live swipe stays under it: a few px, never twice the swipe's own delta. */
export const AXIS_HELD = 24
export const AXIS_TURN = 4
function turnsAxis(movement: Point, delta: Point): boolean {
const alongX = Math.abs(movement.x) >= Math.abs(movement.y)
const held = alongX ? Math.abs(movement.x) : Math.abs(movement.y)
if (held < AXIS_HELD) return false
const on = alongX ? Math.abs(delta.x) : Math.abs(delta.y)
const off = alongX ? Math.abs(delta.y) : Math.abs(delta.x)
return off >= AXIS_TURN && off > 2 * on
}
/** What this event says about the stream. */
export type PhaseRead = {
/** A stream opened here: the first event, or the hand cutting momentum short. */
start: boolean
/** Why it opened, when it did: the first event, after silence, the hand cutting
* a coast short, or turning to the other axis. */
why: "first" | "silence" | "interrupted" | "turned" | null
/** The device is coasting: this delta is not a person and must move nothing. */
momentum: boolean
/** Momentum was interrupted by the hand coming back. */
interrupted: boolean
/** px per ms, per axis. */
velocity: Point
/** Total travel since the stream opened. */
movement: Point
/** Call the stream over after this long with no event. Adapts to the device's own
* rate, so a 120 Hz trackpad is not held to a slow mouse's window. */
endsIn: number
}
const clamp = (v: number) => Math.min(DELTA_MAX, Math.max(-DELTA_MAX, v))
const add = (a: Point, b: Point): Point => ({ x: a.x + b.x, y: a.y + b.y })
const absMax = (p: Point) => Math.max(Math.abs(p.x), Math.abs(p.y))
export function phaseStart(): Phase {
return {
live: false,
opened: false,
momentum: false,
lastAbs: Number.POSITIVE_INFINITY,
lastT: Number.NEGATIVE_INFINITY,
movement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
accelerations: [],
points: [],
merging: [],
endsIn: ENDS_IN_DEFAULT,
}
}
/** Every acceleration factor of the last ANALYZE merged points reads as a model
* coasting: both axes inside the decay band (a still axis reads 0 and abstains), or
* both decaying at exactly the same rate, which a hand does not do. */
function coasting(accelerations: readonly Point[]): boolean {
if (accelerations.length < ANALYZE) return false
return accelerations.slice(-ANALYZE).every((a) => {
const inBand = (v: number) => v === 0 || (v >= ACC_MIN && v <= ACC_MAX)
const locked = a.x === a.y && a.x < 1 && a.x !== 0
return locked || (inBand(a.x) && inBand(a.y))
})
}
/** The window to wait before calling the stream over, from the device's own event
* rate. Doubled while the hand may still be on the glass: ending a live gesture
* early is far worse than ending a coast late. */
function endsIn(deltaTime: number, momentum: boolean): number {
const base = Math.ceil(deltaTime / 10) * 10 * 1.2
return Math.min(
ENDS_IN_MAX,
Math.round(momentum ? base : Math.max(100, base * 2)),
)
}
/** One wheel event. Deltas arrive in px (the caller resolves deltaMode). */
export function phaseFeed(
p: Phase,
e: Wheel,
): { phase: Phase; read: PhaseRead } {
const delta = { x: clamp(e.dx), y: clamp(e.dy) }
const reach = absMax(delta)
// The hand came back mid-coast: this event opens a new stream, and the old one is
// reported interrupted so its decision is not taken twice.
const interrupted =
p.live && p.momentum && reach > Math.max(2, p.lastAbs * HAND_RETURN)
// A wheel never says it ended, so silence does, the slide track's `SWIPE_END`: a
// phase that outlived its stream read the next one's axis off the last one's
// travel and never said `start` again unless a coast was cut short.
const over = e.t - p.lastT > SWIPE_END
const turned = p.live && turnsAxis(p.movement, delta)
const why: PhaseRead["why"] = !p.live
? "first"
: over
? "silence"
: interrupted
? "interrupted"
: turned
? "turned"
: null
let s = why ? { ...phaseStart(), live: true } : p
s = {
...s,
lastAbs: reach,
lastT: e.t,
movement: add(s.movement, delta),
merging: [...s.merging, { dx: delta.x, dy: delta.y, t: e.t }],
}
if (s.merging.length === MERGE) {
// A merged point: the pair's total travel at the pair's mean time.
const sum = s.merging.reduce<Point>(
(acc, w) => add(acc, { x: w.dx, y: w.dy }),
{ x: 0, y: 0 },
)
const t = s.merging.reduce((acc, w) => acc + w.t, 0) / s.merging.length
const prev = s.points[0]
let next = s
if (prev && t > prev.t) {
const dt = t - prev.t
const velocity = { x: sum.x / dt, y: sum.y / dt }
// The ratio to the previous velocity IS the signal. A zero previous velocity
// divides by one so a still axis reads 0 and abstains rather than exploding.
const acceleration = {
x: velocity.x / (s.velocity.x || 1),
y: velocity.y / (s.velocity.y || 1),
}
const accelerations = [...s.accelerations, acceleration].slice(-ANALYZE)
const momentum = s.momentum || coasting(accelerations)
next = {
...s,
velocity,
accelerations,
momentum,
endsIn: endsIn(dt, momentum),
}
}
s = { ...next, points: [{ sum, t }], merging: [] }
} else if (!s.opened) {
// Before a pair exists there is no ratio to take: velocity is this event's travel
// over the window we would have waited anyway, which is the right order.
const w = s.merging[s.merging.length - 1] as Wheel
s = { ...s, velocity: { x: w.dx / s.endsIn, y: w.dy / s.endsIn } }
}
const start = !s.opened
s = { ...s, opened: true }
return {
phase: s,
read: {
start,
why: start ? why : null,
momentum: s.momentum,
interrupted,
velocity: s.velocity,
movement: s.movement,
endsIn: s.endsIn,
},
}
}// The wheel session, framework-free. Three axes, all of them the POSE: a ctrl-wheel
// zoom, a pan while zoomed, and the vertical drag that dismisses. Each offsets the
// pose the hand found (`grab`) and lets silence decide where it lands, which is safe
// because each springs back to a resting place, so a mistimed release costs nothing.
//
// There is no horizontal axis here: sideways wheel belongs to the track. `wheelAxisOf`
// and `wheelIsTrackable` say when a tick is the track's, and the binder hands those
// ticks to the track's own swipe rather than opening a session for them.
//
// `last` is the accepted tick the velocity span is measured against; `at` the last
// cursor; `live` whether a vertical session ever passed the inertia guard. Ticks in,
// a session and effects out; the binder owns the timers and applies the effects.
// `bun scripts/examples/lightbox-wheel.ts`.
import {
type Band,
wheelTick as boundTick,
clamp,
clampPan,
dismissCommit,
dragProgress,
dragScale,
INTENT,
PINCH_CLOSE,
PINCH_PASSED,
type Point,
type Pose,
panBounds,
pinchProgress,
rawPan,
rubber,
type Sample,
type Size,
type View,
velocity,
WHEEL_ZOOM,
wheelIsHand,
wheelPx,
zoomAt,
} from "@/registry/base-nova/lib/lightbox-motion"
import {
type Phase,
phaseFeed,
phaseStart,
} from "@/registry/base-nova/lib/lightbox-wheel-phase"
/** `pass`: the guard rejected this stream (it opened on a coast), so it is ignored
* whole. `dead`: the lightbox is leaving, nothing that follows can mean anything. */
export type WheelAxis = "zoom" | "pan" | "y" | "pass" | "dead"
export type WheelSession = {
axis: WheelAxis
live: boolean
/** The first raw vertical ticks, for the inertia guard. */
ticks: readonly number[]
x: number
y: number
grab: Pose
/** The grab read back through the pan rubber. */
raw0: Point
/** The raw scale the ticks accumulated; the pose wears it rubbered. */
zoom: number
/** The highest scale this pinch reached: a pinch that went past the ceiling first
* is a zoom being undone, not a dismiss. */
pinchMax: number
samples: readonly Sample[]
last: number
at: Point
/** Hand or coast, carried across the session's ticks. The binder reads `endsIn` off
* it to time the silence that ends a session; no landing is decided on it. */
phase: Phase
}
/** One wheel event, in the band's frame. */
export type WheelInput = {
deltaX: number
deltaY: number
deltaMode: number
ctrl: boolean
/** The cursor relative to the band center. */
at: Point
now: number
}
/** What the engine knows at this tick. */
export type WheelCtx = {
pose: Pose
fitted: Size
band: Band
zoomMax: number
/** The visual viewport's height: the dismiss drag's scale. */
vh: number
/** The media is a frame: nothing to zoom. */
frame: boolean
}
export type WheelEffect =
/** The hand took the image: the engine pauses flights and marks the gesture. */
| { kind: "grab" }
/** The hand is done and what follows is only decay: the chrome comes back NOW,
* not when the tail finally dies. */
| { kind: "release" }
| { kind: "pose"; pose: Pose }
| { kind: "exit"; vy: number }
/** At fit, with no ctrl held, a wheel is the TRACK's or the dismiss drag's, and
* which one is decided from the travel so far, not from one event: the first event
* of a two-finger swipe carries whatever the fingers happened to be doing, so
* reading the axis off it hands horizontal swipes to the dismiss and back again at
* random. Vertical must also WIN clearly, because sideways is the common verb here
* and a swipe that drifts a little must not become a dismiss. */
export function wheelIsTrackable(input: WheelInput, ctx: WheelCtx): boolean {
return !input.ctrl && ctx.pose.s <= 1.01
}
/** Undecided until the travel is worth reading. `null` while the stream is still
* too small to have a direction. */
export function wheelAxisOf(travel: Point): "x" | "y" | null {
if (Math.max(Math.abs(travel.x), Math.abs(travel.y)) < INTENT) return null
return Math.abs(travel.y) > 1.5 * Math.abs(travel.x) ? "y" : "x"
}
const begin = (input: WheelInput, ctx: WheelCtx, phase: Phase) => {
const axis: WheelAxis = input.ctrl ? "zoom" : ctx.pose.s > 1.01 ? "pan" : "y"
if (axis === "zoom" && ctx.frame) return null
const session: WheelSession = {
axis,
live: axis !== "y",
ticks: [],
x: 0,
y: 0,
grab: ctx.pose,
raw0: rawPan(ctx.pose, ctx.fitted, ctx.band),
zoom: ctx.pose.s,
pinchMax: ctx.pose.s,
samples: [],
last: 0,
at: input.at,
phase,
}
// A vertical session grabs only once the inertia guard has let it through.
const effects: WheelEffect[] = axis === "y" ? [] : [{ kind: "grab" }]
return { session, effects }
}
/** A tick: a null session starts one (or refuses: a zoom on a frame), a decided one
* ignores the coast that follows, a live one moves the content. Lines and pages
* become px here; the guard reads the tick the device sent, motion reads it
* bounded. Every event is fed to the phase detector first, session or no session,
* because the hand returning mid-coast is what opens the next one. */
export function wheelTick(
session: WheelSession | null,
input: WheelInput,
ctx: WheelCtx,
): { session: WheelSession | null; effects: WheelEffect[] } {
const rawY = wheelPx(input.deltaY, input.deltaMode, ctx.band.h)
const dy = boundTick(rawY)
const fed = phaseFeed(session ? session.phase : phaseStart(), {
dx: wheelPx(input.deltaX, input.deltaMode, ctx.band.h),
dy: rawY,
t: input.now,
})
// The hand came back while the device was still coasting: the old session is over
// and this event opens a new one, from wherever the pose now is.
const live = session && !fed.read.interrupted ? session : null
const opened = live
? { session: { ...live, phase: fed.phase }, effects: [] }
: begin(input, ctx, fed.phase)
if (!opened) return { session: null, effects: [] }
const effects: WheelEffect[] = opened.effects
let w = { ...opened.session, phase: fed.phase }
const axis = w.axis
if (axis === "dead" || axis === "pass") return { session: w, effects }
w = { ...w, last: input.now, at: input.at }
switch (axis) {
case "zoom": {
// The raw accumulator rubbers (soft floor under fit, stiff over the ceiling),
// the way a drag offsets from its grab.
const raw = w.zoom * Math.exp(-dy * WHEEL_ZOOM)
w = { ...w, zoom: raw, pinchMax: Math.max(w.pinchMax, raw) }
// Pinching IN from fit is the dismiss, exactly as it is under two fingers on
// glass: it follows and lights the room instead of rubbering against a floor.
// A trackpad pinch is the same gesture as a phone pinch and a reader who has
// learnt one on one device is owed it on the other. A pinch that went past the
// ceiling first, or one from a zoom, still springs back untouched.
const dismissing =
w.grab.s <= 1.01 && raw < 1 && w.pinchMax < PINCH_PASSED
const s = dismissing ? raw : rubber(raw, 1, ctx.zoomMax)
const v = zoomAt(ctx.pose, s, w.at)
effects.push({
kind: "pose",
pose: {
...v,
p: dismissing ? Math.min(ctx.pose.p, pinchProgress(s)) : ctx.pose.p,
},
})
return { session: w, effects }
}
case "pan": {
const b = panBounds(ctx.pose, ctx.fitted, ctx.band)
const dx = boundTick(wheelPx(input.deltaX, input.deltaMode, ctx.band.h))
// A wheel pan STOPS at the edge. A rubber band is for direct manipulation,
// where the image is under a finger and the give is what says "this is the
// end"; on a trackpad there is nothing under the finger, so all it does is
// freeze the picture at the cap while the reader keeps pushing, and then owe a
// long way home. The ACCUMULATOR is clamped along with the pose, so panning
// back moves on the very first pixel instead of unwinding a debt.
const x = clamp(w.raw0.x + w.x - dx, -b.x, b.x)
const y = clamp(w.raw0.y + w.y - dy, -b.y, b.y)
w = { ...w, x: x - w.raw0.x, y: y - w.raw0.y }
effects.push({ kind: "pose", pose: { ...ctx.pose, x, y } })
return { session: w, effects }
}
case "y": {
// Nothing accumulates before the guard decides: a session it rejects is passed
// through whole, one it accepts starts from its first applied tick, offsetting
// the pose it finds there.
if (!w.live) {
const ticks = [...w.ticks, Math.abs(rawY)]
w = { ...w, ticks }
if (ticks.length < 3) return { session: w, effects }
if (!wheelIsHand(ticks))
return { session: { ...w, axis: "pass" }, effects }
effects.push({ kind: "grab" })
w = { ...w, live: true, grab: ctx.pose }
}
w = { ...w, y: w.y - dy }
const g = w.grab
effects.push({
kind: "pose",
pose: {
x: g.x,
y: g.y + w.y,
s: g.s * dragScale(w.y, ctx.vh),
p: g.p * dragProgress(w.y, ctx.vh),
},
})
w = { ...w, samples: [...w.samples, { x: 0, y: w.y, t: input.now }] }
const v = velocity(w.samples, input.now)
if (dismissCommit(w.y, v.y, ctx.vh)) {
w = { ...w, axis: "dead" }
effects.push({ kind: "release" }, { kind: "exit", vy: v.y })
}
return { session: w, effects }
}
default: {
const never: never = axis
throw new Error(`lightbox: wheel axis ${String(never)}`)
}
}
}
export type WheelRelease =
| { kind: "none" }
/** Zoom let go under fit but not far enough to close: spring back to fit. */
| { kind: "fit" }
/** Pinched in past the close line: leave, the way two fingers do. */
| { kind: "exit" }
/** Zoom let go: undo the rubber at the cursor, then momentum and clamp. */
| { kind: "zoom"; at: Point }
/** Pan let go: back inside the bounds. */
| { kind: "pan"; target: View }
/** A dismiss drag that did not commit: home under the hand's spring, with its speed. */
| { kind: "cancel"; vel: Point }
/** The stream stopped: the session ends and decides where the pose lands. */
export function wheelRelease(
w: WheelSession,
ctx: Pick<WheelCtx, "pose" | "fitted" | "band">,
): WheelRelease {
const v = velocity(w.samples, w.last)
switch (w.axis) {
case "pass":
case "dead":
return { kind: "none" }
case "zoom":
// The same close rule the fingers use, so the two devices agree.
if (ctx.pose.s < PINCH_CLOSE && w.pinchMax < PINCH_PASSED)
return { kind: "exit" }
return ctx.pose.s < 1 ? { kind: "fit" } : { kind: "zoom", at: w.at }
case "pan":
return { kind: "pan", target: clampPan(ctx.pose, ctx.fitted, ctx.band) }
case "y":
return w.live ? { kind: "cancel", vel: v } : { kind: "none" }
default: {
const never: never = w.axis
throw new Error(`lightbox: wheel axis ${String(never)}`)
}
}
}// The pointer state machine, framework-free. Points in, a new state and effects out;
// the binder owns capture, the DOM and the clock. `samples` hold ONE trajectory: the
// finger, or the pinch midpoint; the span is emptied whenever the pointer count
// changes. `anchor` is the last point the hand was at, relative to the band center:
// where a rubbered zoom is undone. `raw0` is the grab read back through the pan
// rubber: a drag offsets it and rubbers the sum, so a grab taken mid-bounce moves
// under the finger from its first px. `bun scripts/examples/lightbox-gesture.ts`.
import {
type Band,
clampPan,
DOUBLE_MOUSE,
DOUBLE_TOUCH,
DOUBLE_TRAVEL,
dismissCommit,
dragProgress,
dragScale,
INTENT,
overshoot,
PINCH_CLOSE,
PINCH_PASSED,
type Point,
type Pose,
panBounds,
pinchProgress,
project,
RELOCK,
rawPan,
rubber,
SAMPLES,
type Sample,
type Size,
TAP_TRAVEL,
type View,
velocity,
zoomAt,
} from "@/registry/base-nova/lib/lightbox-motion"
export type Gesture = {
/** Live pointers, by id, in client px. */
pts: ReadonlyMap<number, Point>
start: Sample
samples: readonly Sample[]
prev: Point
grab: Pose
raw0: Point
axis: "x" | "y" | null
mode: "pan" | "fit"
pinch: { s0: number; p0: number; d0: number; mid0: Point; view0: View } | null
pinched: boolean
pinchMax: number
anchor: Point
/** One run per axis, each restarting when ITS OWN axis turns and counting the other
* from that instant. A relock is a RUN of consistent motion, never one event: at
* 120 Hz a finger sends 2-4 px per move, so a threshold tested per event can only
* be crossed by a jump no hand makes. And the window has to restart on the turn,
* or the ratio is measured against a whole swipe's worth of the other axis and can
* never be beaten either. `runY.x` is unsigned travel, `runY.y` is signed. */
runY: Point
runX: Point
onMedia: boolean
type: string
}
/** One pointer event, with the band-relative point the binder computed. */
export type PointerInput = {
id: number
/** Client px. */
x: number
y: number
/** `at` relative to the band center. */
at: Point
t: number
type: string
onMedia: boolean
/** Coalesced positions of this move, oldest first; the move itself when empty. */
hand?: readonly Sample[]
/** The pinch midpoint relative to the band center; the binder measures it because
* only it knows where the band sits on screen. */
mid?: Point
}
export type GestureCtx = {
pose: Pose
fitted: Size
band: Band
zoomMax: number
vh: number
/** The media cannot be zoomed or pinched (a frame). */
frame: boolean
}
export type GestureEffect =
/** Read the flight at its clock and drop it: the pinch or the relock starts live. */
| { kind: "sync" }
| { kind: "pose"; pose: Pose }
/** Drag the scroll container by this many px (a mouse only; see gestureMove). */
| { kind: "scroll"; dx: number }
/** The image flies back to the grab while the track takes the hand; settles nothing. */
| { kind: "unpose"; target: Pose }
| { kind: "trace"; text: string }
const push = (samples: readonly Sample[], s: Sample): Sample[] => {
const next = [...samples, s]
return next.length > SAMPLES ? next.slice(next.length - SAMPLES) : next
}
const dist = (pts: ReadonlyMap<number, Point>) => {
const [a, b] = [...pts.values()] as [Point, Point]
return Math.hypot(a.x - b.x, a.y - b.y)
}
export const midOf = (pts: ReadonlyMap<number, Point>): Point => {
const [a, b] = [...pts.values()] as [Point, Point]
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
/** A pointer went down: a new gesture, or another finger on the live one. A second
* finger opens the pinch, computed FROM the pose, so the binder syncs first. */
export function gestureDown(
g: Gesture | null,
input: PointerInput,
ctx: GestureCtx,
): { gesture: Gesture; effects: GestureEffect[] } {
const effects: GestureEffect[] = []
let next: Gesture
if (g) {
next = {
...g,
pts: new Map(g.pts).set(input.id, { x: input.x, y: input.y }),
}
effects.push({
kind: "trace",
text: `down ${input.type} #${input.id} +finger`,
})
} else {
const mode: Gesture["mode"] = ctx.pose.s > 1.01 ? "pan" : "fit"
next = {
pts: new Map([[input.id, { x: input.x, y: input.y }]]),
start: { x: input.x, y: input.y, t: input.t },
samples: [],
prev: { x: input.x, y: input.y },
grab: ctx.pose,
raw0: rawPan(ctx.pose, ctx.fitted, ctx.band),
axis: null,
mode,
pinch: null,
pinched: false,
pinchMax: ctx.pose.s,
anchor: input.at,
runY: { x: 0, y: 0 },
runX: { x: 0, y: 0 },
onMedia: input.onMedia,
type: input.type,
}
effects.push({
kind: "trace",
text: `down ${input.type} #${input.id} ${mode} ${input.onMedia ? "media" : "backdrop"}`,
})
}
if (next.pts.size === 2 && !ctx.frame) {
// The pinch is computed FROM the pose: a flight in progress (the y-to-x relock)
// is read and dropped first, so s0 and view0 are the live pose.
effects.push({ kind: "sync" })
next = {
...next,
pinch: {
s0: ctx.pose.s,
p0: ctx.pose.p,
d0: dist(next.pts),
mid0: input.mid ?? { x: 0, y: 0 },
view0: ctx.pose,
},
pinched: true,
axis: null,
samples: [],
}
effects.push({ kind: "trace", text: "pinch start" })
}
return { gesture: next, effects }
}
/** A move: the pinch follows the fingers, a pan offsets the rubbered grab, and a
* single finger at fit locks an axis at INTENT and relocks at RELOCK. */
export function gestureMove(
g: Gesture,
input: PointerInput,
ctx: GestureCtx,
): { gesture: Gesture; effects: GestureEffect[] } {
const effects: GestureEffect[] = []
let next: Gesture = {
...g,
pts: new Map(g.pts).set(input.id, { x: input.x, y: input.y }),
}
const pinch = next.pinch
if (pinch && next.pts.size >= 2) {
const ms = midOf(next.pts)
next = {
...next,
samples: push(next.samples, { x: ms.x, y: ms.y, t: input.t }),
}
const raw = (pinch.s0 * dist(next.pts)) / pinch.d0
next = { ...next, pinchMax: Math.max(next.pinchMax, raw) }
// From fit, pinching in is the dismiss gesture and follows the fingers, lighting
// the room, the same rule the release closes by; a pinch that went past the
// ceiling first, or one from a zoom, rubbers under 1 and springs back, the room
// untouched.
const dismissing =
pinch.s0 <= 1.01 && raw < 1 && next.pinchMax < PINCH_PASSED
const s = dismissing ? raw : rubber(raw, 1, ctx.zoomMax)
const m = input.mid ?? next.anchor
next = { ...next, anchor: m }
const v = zoomAt(pinch.view0, s, pinch.mid0)
const px = v.x + m.x - pinch.mid0.x
const py = v.y + m.y - pinch.mid0.y
// The pinch's pan wears the same band the drag does, against the bounds of the
// scale it is CURRENTLY at. Unbounded, pinching out left the image hundreds of px
// outside a bound that had just shrunk under it, which is a pose no rubber could
// have produced: the next finger down asked `rawPan` to undo a rubbering that
// never happened, and it threw, and a thrown `gestureDown` wedges every touch
// after it. Dismissing is the exception, being a pinch whose whole point is to
// carry the picture away, and its bounds are meaningless because it is under fit.
const b = panBounds({ x: 0, y: 0, s }, ctx.fitted, ctx.band)
// A pinch that starts mid-drag carries the drag's darkness: p never jumps.
effects.push({
kind: "pose",
pose: {
x: dismissing ? px : overshoot(px, b.x),
y: dismissing ? py : overshoot(py, b.y),
s,
p: dismissing ? Math.min(pinch.p0, pinchProgress(s)) : pinch.p0,
},
})
return { gesture: next, effects }
}
if (next.pts.size !== 1) return { gesture: next, effects }
for (const s of input.hand ?? [{ x: input.x, y: input.y, t: input.t }])
next = { ...next, samples: push(next.samples, s) }
next = { ...next, anchor: input.at }
const dx = input.x - next.start.x
const dy = input.y - next.start.y
const mx = input.x - next.prev.x
const my = input.y - next.prev.y
// Each run restarts when its own axis turns, so a wobble cannot accumulate into an
// intent and a steady push is never diluted by the travel that came before it.
const turnedY = my !== 0 && Math.sign(my) !== Math.sign(next.runY.y)
const turnedX = mx !== 0 && Math.sign(mx) !== Math.sign(next.runX.x)
next = {
...next,
prev: { x: input.x, y: input.y },
runY: turnedY
? { x: Math.abs(mx), y: my }
: { x: next.runY.x + Math.abs(mx), y: next.runY.y + my },
runX: turnedX
? { x: mx, y: Math.abs(my) }
: { x: next.runX.x + mx, y: next.runX.y + Math.abs(my) },
}
if (next.mode === "pan") {
const b = panBounds(next.grab, ctx.fitted, ctx.band)
effects.push({
kind: "pose",
pose: {
...next.grab,
x: overshoot(next.raw0.x + dx, b.x),
y: overshoot(next.raw0.y + dy, b.y),
},
})
return { gesture: next, effects }
}
// The finger left after a pinch drifts a few px: that is never a drag.
if (next.axis === null) {
if (Math.abs(dx) + Math.abs(dy) < INTENT) return { gesture: next, effects }
const axis = next.pinched || Math.abs(dx) <= Math.abs(dy) ? "y" : "x"
next = { ...next, axis }
if (axis === "y") effects.push({ kind: "trace", text: "axis y" })
} else if (
// A RUN, not an event. Measured on a phone: this is what turns a swipe through
// the pictures into a dismiss without lifting, and it never fired, because at
// 120 Hz a finger sends 2-4 px per move and `|my| > 12` in ONE of them is a jump
// no hand makes. A headless rig stepping 30 px at a time passed it happily, which
// is the whole reason a rig does not grant sign-off.
next.axis === "x" &&
Math.abs(next.runY.y) > RELOCK &&
Math.abs(next.runY.y) > 2 * next.runY.x
) {
next = { ...next, axis: "y" }
effects.push({ kind: "trace", text: "axis y" }, { kind: "sync" })
} else if (
// The mirror, on its own run for the same reason.
next.axis === "y" &&
!next.pinched &&
Math.abs(next.runX.x) > RELOCK &&
Math.abs(next.runX.x) > 2 * next.runX.y
) {
next = { ...next, axis: "x" }
effects.push(
{ kind: "trace", text: "axis x (relock)" },
{ kind: "unpose", target: next.grab },
)
}
if (next.axis === "y") {
effects.push({
kind: "pose",
pose: {
x: next.grab.x,
y: next.grab.y + dy,
s: next.grab.s * dragScale(dy, ctx.vh),
p: next.grab.p * dragProgress(dy, ctx.vh),
},
})
} else {
// Sideways is the track's, and the ENGINE carries it, for every pointer. The
// slot is `touch-action: none`, so the browser never claims the pan and there is
// never a second mover — which is the only reason this is safe. It was a bug
// once under `pan-x`: the axis locks at INTENT (6px) several moves before the
// compositor commits, so those moves wrote `scrollLeft` on a scroller the
// compositor was also moving, then the platform took the pan and the magnets
// came back on between two slides.
//
// Handing the pan to the browser fixed that and cost something worse. Once it
// claims the touch, `pointercancel` ends OUR gesture with the finger still down,
// so the rest of that touch can never become anything else: a reader swiping
// through pictures who then swipes UP to dismiss is refused, with no way to know
// why, until they lift and start again. A dismiss that clear is not an accident,
// and nobody moves in a perfectly straight line.
effects.push({ kind: "scroll", dx: mx })
}
return { gesture: next, effects }
}
export type GestureRelease =
/** Fingers remain: the gesture regrabs around the one left. */
| { kind: "hold"; gesture: Gesture }
/** A pinch let go under the close rule: leave, with the hand's velocity. */
| { kind: "exit"; vel: Point }
/** A pinch or a dismiss that did not commit: home under the hand's spring.
* `zoomed` when a pinch let go under fit, the one case React still reads 1. */
| { kind: "cancel"; vel: Point; zoomed: boolean }
/** A pinch over fit: undo the rubber at the anchor, then momentum and clamp. */
| { kind: "zoom"; vel: Point; at: Point }
/** Nothing moved far enough to be a drag. */
| { kind: "tap"; at: Point; x: number; y: number; t: number }
/** A pan flick: coast, and bounce off whichever wall cuts it short. */
| { kind: "coast"; coast: View; target: View; vel: Point }
/** An axis was locked but the release decides nothing: resume what was flying. */
| { kind: "resume" }
/** A mouse let the track go: hand it back to the platform's snap. */
| { kind: "snap" }
/** A pointer lifted (or cancelled). The release instant is the clock, not a sample:
* a hand held still before lifting reads as stopped, a mouse button lifting late
* keeps the hand's speed. */
export function gestureUp(
g: Gesture,
input: PointerInput,
ctx: GestureCtx,
): GestureRelease {
const pts = new Map(g.pts)
pts.delete(input.id)
if (pts.size === 1) {
const [p] = [...pts.values()] as [Point]
return {
kind: "hold",
gesture: {
...g,
pts,
pinch: null,
samples: [],
grab: ctx.pose,
raw0: rawPan(ctx.pose, ctx.fitted, ctx.band),
start: { x: p.x, y: p.y, t: input.t },
prev: p,
runY: { x: 0, y: 0 },
runX: { x: 0, y: 0 },
axis: null,
mode: ctx.pose.s > 1.01 ? "pan" : "fit",
},
}
}
if (pts.size > 0) return { kind: "hold", gesture: { ...g, pts } }
const v = velocity(g.samples, input.t)
if (g.pinched) {
const s = ctx.pose.s
if (s < PINCH_CLOSE && g.pinchMax < PINCH_PASSED)
return { kind: "exit", vel: v }
if (s < 1) return { kind: "cancel", vel: v, zoomed: true }
return { kind: "zoom", vel: v, at: g.anchor }
}
const travel = Math.hypot(input.x - g.start.x, input.y - g.start.y)
if (g.axis === null && travel < TAP_TRAVEL)
return { kind: "tap", at: input.at, x: input.x, y: input.y, t: input.t }
// A pan has no axis (it moves on both): its release is the coast, before the axis
// test, or a flick would resume a stale aim and stop dead past the bound.
if (g.mode === "pan") {
const coast = {
x: project(ctx.pose.x, v.x),
y: project(ctx.pose.y, v.y),
s: ctx.pose.s,
}
return {
kind: "coast",
coast,
target: clampPan(coast, ctx.fitted, ctx.band),
vel: v,
}
}
if (g.axis === null) return { kind: "resume" }
if (g.axis === "y") {
// The rule is about drag distance: mid-fly the absolute y still holds the source
// offset, so the delta from the grab is what is tested. The axis is locked:
// lateral hand speed never reaches the spring.
const vy = { x: 0, y: v.y }
return dismissCommit(ctx.pose.y - g.grab.y, v.y, ctx.vh)
? { kind: "exit", vel: vy }
: { kind: "cancel", vel: vy, zoomed: false }
}
// The hand dragged the scroller and the engine has to land it, whatever the hand
// was. A pointer SAYS when it let go, which is the one thing a wheel never does,
// so this needs no threshold, no silence and no guessing.
return { kind: "snap" }
}
export type TapIntent =
/** Nothing but settle what was flying. */
| { kind: "settle" }
| { kind: "escape" }
| { kind: "zoom"; at: Point }
| { kind: "chrome" }
/** A finger's first tap at fit: the chrome toggles unless a second tap lands. */
| { kind: "wait"; ms: number }
/** The tap ladder. `last` is the previous tap of this session (the binder holds it,
* and clears it when the wait fires). A pointer with a cursor is told what a click
* does (zoom-in, zoom-out): one click toggles the zoom at the point, and the second
* click of a double click is the same intent, already served. A finger has no
* cursor: one tap toggles the chrome, two toggle the zoom. */
export function tapIntent(
last: Sample | null,
tap: { at: Point; x: number; y: number; t: number; type: string },
g: Pick<Gesture, "onMedia">,
ctx: Pick<GestureCtx, "pose"> & { kind: string },
): { last: Sample | null; intents: TapIntent[] } {
const keep = { x: tap.x, y: tap.y, t: tap.t }
// Off the picture a tap is Escape, zoomed or not: the ladder (`escRung`) takes it
// a rung down, back to fit when zoomed, closed at fit. Zoomed, it used to do
// nothing, and the dark beside a zoomed picture was the one place a click was dead.
if (!g.onMedia) return { last: null, intents: [{ kind: "escape" }] }
// A tap on a video is the video's (its controls, the platform's play toggle); a
// tap on a frame is the frame's.
if (ctx.kind === "video" || ctx.kind === "frame")
return { last: null, intents: [{ kind: "settle" }] }
const span = tap.type === "touch" ? DOUBLE_TOUCH : DOUBLE_MOUSE
const again =
last !== null &&
tap.t - last.t < span &&
Math.hypot(tap.x - last.x, tap.y - last.y) < DOUBLE_TRAVEL
if (tap.type !== "touch") {
if (again) return { last: null, intents: [{ kind: "settle" }] }
return { last: keep, intents: [{ kind: "zoom", at: tap.at }] }
}
if (again) return { last: null, intents: [{ kind: "zoom", at: tap.at }] }
return {
last: keep,
intents:
ctx.pose.s <= 1.01
? [{ kind: "wait", ms: DOUBLE_TOUCH }, { kind: "settle" }]
: [{ kind: "settle" }],
}
}