/** * FrameConductor — the single rAF loop every VV effect subscribes to, and the * scheduler that decides what actually gets to run inside it. * * Rules it enforces: * - One loop per page. Effects never create private rAF loops. * - Lanes run in a fixed order each frame: input → update → render, * so readers (sensors) always run before writers (draw calls). * - The loop only runs while it has subscribers (zero idle cost). * - dt is clamped so a backgrounded tab waking up doesn't feed a huge * delta into spring/damping math. * * ## Scheduling (v0.3) * * A shared loop is tidy. A shared loop that *makes decisions* is the reason * this is a runtime and not a utility. Subscribers declare what they are: * * - `essential` — sensors, governors, direct manipulation. Always runs. * - `enhanced` — interaction feedback. Shed once the frame is nearly spent. * - `decorative` — ambient garnish. Shed first. * * When a frame runs long, low-priority work is skipped FOR THAT FRAME rather * than letting everything degrade together — so ten coordinated effects can * cost less than three uncoordinated ones. Nothing starves: a subscriber * skipped `MAX_CONSECUTIVE_SHED` times in a row is forced through, so heavy * pages degrade decorative work to a lower framerate instead of freezing it. * * ## Carried overrun (v0.4) * * Shedding used to compare only our own elapsed work against the budget. That * made it unreachable in the normal case: when React, style, layout, paint or * the GPU are what is eating the frame, our subscribers might total 3ms of a * 16.6ms budget while the frame actually lands in 28ms. We would measure 3ms, * conclude there was room, and run everything — on a page visibly at 29fps. * * So the budget each frame is reduced by how far the PREVIOUS frame overran, * taken from the wall clock: a frame that landed 11ms late leaves about 5ms to * spend rather than 16.6, and every band scales down with it. The debt is what * the interval says, whoever caused it. * * Reducing the budget rather than pre-spending the frame is deliberate. * Pre-spending collapses the design under load — at full debt every threshold * lies below the starting position, so all three bands shed on frame entry and * priority stops meaning anything precisely when it matters most. * * The debt rises quickly and decays slowly on purpose. Symmetric smoothing * oscillates — shedding rescues the frame, the debt clears, the work returns, * the frame blows out again. Slow decay holds the quality decision steady * until the page has been healthy for a while. * * `hz` throttles a subscriber to a slower cadence. The accumulated dt is * passed through, so frame-rate-independent damping stays correct at any * cadence — an ambient background at 30Hz looks identical and costs half. * * ## Attribution * * Per-subscriber cost measurement is free: the scheduler already has to read * the clock after each subscriber to know how much of the frame is left, so * the shed decision and the cost breakdown come from the same timestamp. * There is exactly one `performance.now()` call per executed subscriber. */ type ConductorLane = "input" | "update" | "render"; type FrameFn = (dt: number, time: number) => void; /** What a subscriber is worth when the frame runs out of room. */ type SubscriberPriority = "essential" | "enhanced" | "decorative"; interface SubscribeOptions { /** * Shed order under load. Default `"enhanced"`. * * `"essential"` is never shed — reserve it for sensors, governors and * direct manipulation (a scrub that stutters is a broken scrub). * * `"decorative"` sheds first, and under sustained load lands on the * starvation floor of roughly 12fps. That is right for ambient work and * wrong for anything whose position a viewer follows — pair it with `hz` in * that case, and read the note there. */ priority?: SubscriberPriority; /** * Cap this subscriber's cadence, in runs per second. Omit or `0` for every * frame. The dt passed in accumulates, so damping math stays correct. * * **Declare this for any decorative work whose motion the eye tracks.** * * Shedding does not stutter — the starvation guard forces a skipped * subscriber through after four frames, so heavily shed work runs on a * perfectly regular beat. The problem is which beat: every fifth frame is * 12fps at 60Hz, and 12fps reads as broken for anything whose *position* is * being followed, however even it is. Film is 24. * * Measured on the benchmark: one decorative element left to shedding ran on * a 5-frame gap 59 times out of 59 — regular, and visibly bad. The same * element with `hz: 30` ran on a 2-frame gap, looked fine, and did *less* * total work than the shed version. * * So this is not a consolation prize for slow work. For tracked motion it is * better looking and cheaper than the alternative. * * Work that degrades gracefully — a shader, a particle field, an ambient * canvas — does not need it. Rendering that slightly less often is not * something anyone can point at. */ hz?: number; /** Name shown in devtools and in slow-subscriber warnings. */ label?: string; /** * Which interaction scope this work belongs to. While some OTHER scope holds * the foreground lease, this subscriber sheds one band earlier than its * priority would normally allow. Omit for work that belongs to no particular * region — that is treated as background whenever any lease is held. * * `essential` is never affected, whatever the scope. */ scope?: string; } interface SubscriberStat { label: string; lane: ConductorLane; priority: SubscriberPriority; /** Smoothed execution time of this subscriber alone, in ms. */ costMs: number; /** Most recent execution time, in ms. */ lastCostMs: number; /** Throttle cadence, or `null` when it runs every frame. */ hz: number | null; /** Frames this subscriber has executed. */ runs: number; /** Frames skipped because the frame ran out of budget. */ shed: number; /** * The interaction region this work belongs to, or `null` for background * work. Compare against `ConductorStats.activeScope` to see what is * currently protected. */ scope: string | null; } interface ConductorStats { running: boolean; /** Detected display refresh rate. 60 until the probe settles. */ displayHz: number; /** One presented frame, in ms — the budget everything is measured against. */ frameBudgetMs: number; /** Smoothed frames per second, from the rAF interval. */ fps: number; /** Smoothed rAF-to-rAF interval, in ms. */ frameMs: number; /** Smoothed time this runtime spent executing subscribers, in ms. */ workMs: number; /** Decaying peak of `workMs`, in ms. */ worstWorkMs: number; /** * How much of this frame was already spent before we ran anything, carried * from the previous frame's overrun. Non-zero means something outside this * runtime is eating the frame, and it is why low-priority work is shedding. */ carriedOverrunMs: number; /** Scope currently holding the foreground lease, or null. */ activeScope: string | null; /** Human-readable name of that scope, when one was given. */ activeScopeLabel: string | null; /** * How much of this frame was already gone before the runtime got it, in ms, * smoothed. Every rAF callback in a frame receives the same start timestamp, * so the gap between that and the moment our tick actually runs is other * people's frame work — a third-party library's own loop, most often. * * Large values mean the page is main-thread bound by something that is not * us, which is the one case where shedding our own work helps least. */ preRuntimeMs: number; subscriberCount: number; /** Subscribers skipped on the most recent frame. */ shedLastFrame: number; /** Per-subscriber breakdown, most expensive first. */ subscribers: SubscriberStat[]; } interface ConductorConfig { /** * Drop low-priority work when a frame runs long. Default `true` — turning * it off makes every subscriber run unconditionally, as in v0.1. */ shedding?: boolean; /** * Called instead of `console.error` when a subscriber throws. The loop * always continues regardless. */ onError?: (error: unknown, label: string, lane: ConductorLane) => void; /** * Warn once per subscriber that exceeds this execution time, in ms. * `0` disables. Default `0`. */ slowSubscriberMs?: number; } declare class FrameConductor { #private; /** Innermost-last. The tail holds the foreground lease. */ /** * Tune scheduling and diagnostics. Safe to call at any time; partial — * omitted fields keep their current value. */ configure(config: ConductorConfig): void; /** Detected display refresh rate in Hz. */ get displayHz(): number; /** One presented frame in ms — what "over budget" is measured against. */ get frameBudgetMs(): number; /** * Time this runtime spent executing subscribers on the previous frame, in * ms, smoothed. Read from the input lane (which runs first) this is the * completed previous frame — the same vintage as `dt`. */ get workMs(): number; /** The scope currently holding the foreground claim, or null. */ get foregroundScope(): string | null; /** * The label of the scope holding the foreground claim, for display. Scope ids * are generated, so this is the only part a human can read. */ get foregroundLabel(): string | null; /** * Claim the foreground for an interaction. Returns a release function. * * ```ts * const release = getConductor().claimScope("gallery"); * // on pointerup: * release(); * ``` * * While a lease is held, every subscriber that did NOT declare this scope * sheds one band earlier — so ambient work elsewhere on the page gives up its * frame time to the thing the user is actually touching. Priorities are a * fixed statement about what work is worth; a lease is a live statement about * where attention currently is. Essential work is exempt either way. * * Claims nest. The most recent holds the lease, and releasing restores the one * beneath it. Releasing twice is a no-op. */ claimScope(scope: string, label?: string): () => void; subscribe(lane: ConductorLane, fn: FrameFn, options?: SubscribeOptions): () => void; /** * A snapshot for devtools and HUDs. Allocates — call it at a human refresh * rate (a few times a second), never inside a frame loop. */ getStats(): ConductorStats; } /** Lazy singleton — safe to import in SSR modules, only constructed on use. */ declare function getConductor(): FrameConductor; export { type ConductorConfig as C, type FrameFn as F, type SubscribeOptions as S, type ConductorLane as a, type ConductorStats as b, type SubscriberPriority as c, type SubscriberStat as d, getConductor as g };