/** Shared math for the motion core. */ /** * Frame-rate-independent exponential damping — identical feel at 30 and * 120fps. `k` is responsiveness per second; higher = tighter tracking. */ declare function damp(current: number, target: number, k: number, dt: number): number; declare function clamp01(v: number): number; interface RectLike { left: number; top: number; right: number; bottom: number; } /** * First time (seconds) at which a point moving at velocity (vx, vy) enters * the rect, or null if the ray misses. Slab method; a point already inside * returns 0. Zero velocity on an axis requires the point to already be * within that axis's span. */ declare function rayRectIntersect(x: number, y: number, vx: number, vy: number, rect: RectLike): number | null; /** * SensorBus — ONE set of input listeners for the whole page. * * Ten effects that each attach pointermove/scroll/resize listeners and each * compute their own velocities do ten times the work for one answer. The * bus attaches passive listeners once, computes smoothed derivatives once * per frame (conductor input lane — before any consumer runs), and exposes * a live snapshot consumers READ during their own frame work. * * Consumption contract (deliberate, GSAP-style pragmatism): `.state` * returns live internal objects — read fields each frame, never retain or * mutate them. There are no per-event callbacks; the conductor IS the * delivery mechanism. * * Lifecycle is ref-counted: `retain()` starts the sensors, the returned * release stops them when the last consumer lets go. */ interface PointerSensor { /** The current pointer X coordinate in client space (CSS pixels). */ x: number; /** The current pointer Y coordinate in client space (CSS pixels). */ y: number; /** The damped horizontal velocity of the pointer in pixels per second. */ vx: number; /** The damped vertical velocity of the pointer in pixels per second. */ vy: number; /** The current speed of the pointer in pixels per second (magnitude of velocity vector). */ speed: number; /** True if the primary pointer button (e.g., mouse left click or screen touch) is currently held down. */ down: boolean; /** Set to true after the first pointer movement is detected. Useful to prevent initial jump calculations. */ seen: boolean; } interface ScrollSensor { /** The current horizontal scroll position (window.scrollX). */ x: number; /** The current vertical scroll position (window.scrollY). */ y: number; /** The damped horizontal scroll velocity in pixels per second. */ vx: number; /** The damped vertical scroll velocity in pixels per second. */ vy: number; } interface ViewportSensor { /** The width of the viewport layout boundary (window.innerWidth). */ width: number; /** The height of the viewport layout boundary (window.innerHeight). */ height: number; /** The device pixel ratio (DPR) of the current display. */ dpr: number; } interface SensorState { /** Pointer movement and interaction metrics. */ pointer: PointerSensor; /** Window scroll position and scroll velocity metrics. */ scroll: ScrollSensor; /** Window layout bounds and device pixel ratio. */ viewport: ViewportSensor; } /** * SensorBus manages a single, centralized set of passive listeners for the entire page. * It coordinates mouse/touch inputs, page scrolls, and resize events, computing smoothed * velocities and device ratios exactly once per frame on the animation budget loop. * * Rather than creating multiple individual listeners that trigger layout thrashing, components * and hooks read from this singleton bus state inside their frame ticks. */ declare class SensorBus { #private; /** * Retrieves the current snapshot of all active sensor states. * * @readonly * @type {SensorState} */ get state(): SensorState; /** * Retains the sensor bus by incrementing its reference count. * If this is the first reference, it attaches all event listeners and schedules * the update loop. * * @returns {() => void} A release function that decrements the reference count and cleans up listeners if reference count reaches zero. */ retain(): () => void; } /** * Lazy singleton retriever for the unified SensorBus instance. * Safe for use in Server-Side Rendering (SSR) environments. * * @returns {SensorBus} The singleton SensorBus instance. */ declare function getSensorBus(): SensorBus; /** * AnimationBudget — the frame-headroom governor. * * Measures real frame intervals on the shared conductor and collapses them * into a coarse quality tier every effect can consume. The contract: * * - tier 0 "high": frames are healthy, run the designed look * - tier 1 "medium": sustained drops to ~90% of the display rate — shed extras * - tier 2 "low": sustained drops to ~50% of the display rate — survival mode * * Hysteresis is asymmetric BY DESIGN: degrading is fast (users feel jank * within a second), upgrading is slow and cautious (8s of clean frames), * with a cooldown so quality never flaps. A burst of very-slow frames can * jump straight to tier 2. * * The decision logic lives in BudgetPolicy — a pure class with no browser * dependencies, unit-tested by feeding synthetic frame times. The exported * singleton is just conductor plumbing around it. * * ## Refresh-rate relative (v0.3) * * Thresholds were absolute (54fps / 30fps), which told a 120Hz display * limping at 70fps that everything was fine. They are now multiples of the * measured frame budget. The factors are chosen to reproduce the old * constants EXACTLY at 60Hz (18.5ms and 34ms), so 60Hz behaviour is * unchanged and only high-refresh displays start telling the truth. * * ## Honest headroom (v0.3) * * `headroom` used to be `frameBudget - dt`. Because rAF is pinned to vsync, * dt on a healthy 60Hz page is 16.6ms — so a perfectly idle page reported * ~0ms of headroom and the whole signal was unusable below 60Hz. It now * answers the question it always claimed to: **how much of this frame is * still free?** * * - While frames are landing on time, the only consumption we can attribute * is the runtime's own measured work, so headroom is `budget - work`. * - Once a frame runs slow, the wall clock is the truth — something off our * books is eating the frame — so consumption is the whole interval and * headroom goes negative by the overrun. * * Smoothed as an EMA so one spike doesn't tank it. Use it with * `useSafeToMount` to pre-check before mounting expensive components, * rather than waiting for the tier to degrade. */ type BudgetTier = 0 | 1 | 2; interface BudgetState { tier: BudgetTier; label: "high" | "medium" | "low"; /** Rolling average frame interval, in milliseconds. */ avgFrameMs: number; /** Share of recent frames slower than the degradation line, 0..1. */ slowRatio: number; /** * Estimated milliseconds still free in the current frame budget, smoothed. * Positive = room available; zero or negative = already over budget. Use * this as a leading indicator before mounting expensive components — see * `useSafeToMount`. */ headroom: number; /** * Milliseconds the motion runtime itself spent on the last frame, smoothed. * Everything else in `avgFrameMs` belongs to the browser, React, or * third-party scripts. */ workMs: number; /** One presented frame at the detected display rate, in milliseconds. */ frameBudgetMs: number; } type BudgetListener = (state: BudgetState) => void; declare class AnimationBudget { #private; constructor(); get state(): BudgetState; /** * Discard the rolling window. Call on SPA route changes: a new scene should * not be judged by the frames the previous one produced. */ reset(): void; /** Emits current state immediately, then on tier changes + a slow heartbeat. */ subscribe(fn: BudgetListener): () => void; } /** Lazy singleton — client-only construction, SSR-import-safe. */ declare function getAnimationBudget(): AnimationBudget; /** * AdaptiveQuality — the complete quality signal: static device heuristics * fused with the live AnimationBudget. * * Why both: the budget measures TRUTH but only after frames accumulate — * the first seconds are blind. Device signals guess instantly but never * learn. Fusion rule: `tier = max(deviceTier, budgetTier)` — the device * tier is a FLOOR. Without it, weak devices oscillate: degrade → frames * recover (because we degraded) → upgrade → jank → degrade, forever. * A software renderer stays a software renderer; the floor encodes that. * * Own heuristics, zero deps (no GPU benchmark databases): conservative, * explainable (`reasons`), refined by measurement rather than trusted. */ interface DeviceSignals { webgl2: boolean; /** GPU renderer string (unmasked where available), "" if unknown. */ renderer: string; /** navigator.deviceMemory in GB, null if unsupported. */ deviceMemory: number | null; /** navigator.hardwareConcurrency, null if unsupported. */ cores: number | null; reducedMotion: boolean; } interface AdaptiveState { /** Effective tier: max(deviceTier, budgetTier). Consume THIS one. */ tier: BudgetTier; label: "high" | "medium" | "low"; deviceTier: BudgetTier; budgetTier: BudgetTier; /** Human-readable trail of why the device tier is what it is. */ reasons: string[]; reducedMotion: boolean; } type AdaptiveListener = (state: AdaptiveState) => void; declare class AdaptiveQuality { #private; get state(): AdaptiveState; subscribe(fn: AdaptiveListener): () => void; } /** Lazy singleton — client-only construction, SSR-import-safe. */ declare function getAdaptiveQuality(): AdaptiveQuality; /** * FramePressure — what is actually eating the frame. * * AnimationBudget can tell you the page is late. It cannot tell you why, and * the difference decides what to do about it. Shedding our own work when the * GPU is the bottleneck removes motion and fixes nothing; lowering render * quality when a third-party script is blocking the main thread does the same. * * ## The decomposition * * One frame is split three ways: * * frameMs = runtimeMs + mainOtherMs + offThreadMs * * - `runtimeMs` — our own subscribers. The conductor already measures this, * because it has to read the clock between subscribers * anyway to know how much of the frame is left. * - `mainOtherMs` — main-thread work that is not ours, on both sides of our * tick: another library's rAF loop, style, layout, paint. * - `offThreadMs` — the remainder. Compositing, the GPU, and on a healthy * page, simply waiting for the next vsync. * * That last point is why this only classifies when a frame is over budget. On * a page hitting 60fps, `offThreadMs` is mostly idle, and reading idle as * "render pressure" would be worse than saying nothing. * * ## How the main-thread share is measured * * Two numbers, because work happens on both sides of us. * * **After us:** a message is posted to a `MessageChannel` from the input lane. * Messages are delivered as tasks, and a task only runs once the main thread * finishes what it is doing — at that point in the frame, the rest of the * rendering steps. Measured directly in Chrome: with a competing rAF callback * burning 24ms, the round trip came back at 24.3ms. Our own update and render * work is inside that number too, so the conductor's separate measurement is * subtracted back out. * * **Before us:** `ConductorStats.preRuntimeMs`. Every rAF callback in a frame * receives the same start timestamp, so the gap between it and the moment our * tick runs is whatever ran first. Without this, a third-party loop registered * ahead of ours is invisible to the probe and lands in `offThreadMs` — which * made a pure main-thread load report as `"render"` at 97% confidence on the * first real-browser run. * * The probe is sampled at roughly 10Hz rather than every frame. The verdict * only changes slowly, and a probe running at frame rate is measuring a cost * it is helping to create. * * ## What this deliberately does NOT claim * * **It does not measure GPU time.** No browser exposes that without an * `EXT_disjoint_timer_query` on a WebGL context the runtime does not own. * `"render"` means "the main thread was free and the frame was still late", * which points at compositing or the GPU without proving which. * * **Long tasks are a positive signal only.** The Long Task API fires above * 50ms, so a page spending 25ms per frame on main-thread JS produces no long * tasks at all. Seeing one is evidence of main-thread pressure; not seeing one * is evidence of nothing. * * **The decomposition is serial; the pipeline is not.** Frame time is closer to * the longer of the CPU and GPU paths than to their sum, so when the main * thread is the bottleneck, GPU time hides inside it and `offThreadMs` shrinks. * Measured under 6x CPU throttling, a GPU load that read as `"render"` at * normal speed correctly read as `"main-thread"` — the CPU could no longer feed * the GPU fast enough, so the CPU genuinely was the bottleneck. The verdict * stays actionable, because the answer in that case really is "fix the main * thread, do not reduce quality". But do not read `offThreadMs` as a measure of * how much GPU work exists; it measures how much of the frame the GPU was the * thing being waited on. * * **Nothing acts on this yet.** It reports. Shedding, quality tiers and mount * gates are unchanged. A classifier that is wrong is worse than no classifier, * so it earns the right to drive decisions by being read first. */ /** What is eating the frame. */ type PressureSource = /** Frames are healthy. Nothing is being eaten. */ "none" /** Our own subscribers are the largest cost. Shedding them will help. */ | "runtime" /** Someone else's main-thread work. Delay mounts and warm-up, do not shed. */ | "main-thread" /** The main thread was free and the frame was still late. Lower quality. */ | "render" /** Not enough evidence. Hold quality and change nothing. */ | "unknown"; interface PressureState { source: PressureSource; /** * How clearly the winner beat the runner-up, 0–1. Below ~0.3 the verdict is * a lean, not a finding. Anything acting on this should require a floor. */ confidence: number; /** Smoothed wall-clock frame interval, ms. */ frameMs: number; /** Smoothed time in our own subscribers, ms. */ runtimeMs: number; /** * Main-thread time this frame that is NOT ours, ms — work before our tick * plus work after it. `null` until the probe has reported at least once. */ mainOtherMs: number | null; /** Smoothed remainder — compositing, GPU, and vsync wait, ms. */ offThreadMs: number; /** One presented frame at the detected display rate, ms. */ budgetMs: number; /** Long tasks seen since the last emit. */ longTasks: number; } type PressureListener = (state: PressureState) => void; declare class FramePressure { #private; get state(): PressureState; subscribe(fn: PressureListener): () => void; } /** Lazy singleton. Client-only construction, SSR-import-safe. */ declare function getFramePressure(): FramePressure; export { type AdaptiveState as A, type BudgetState as B, type DeviceSignals as D, type PointerSensor as P, type RectLike as R, type ScrollSensor as S, type ViewportSensor as V, type BudgetTier as a, type PressureSource as b, type PressureState as c, SensorBus as d, type SensorState as e, clamp01 as f, damp as g, getAdaptiveQuality as h, getAnimationBudget as i, getFramePressure as j, getSensorBus as k, rayRectIntersect as r };