# Changelog

## Unreleased

## 1.19.2 (2026-10-16)

### Fixed

- Mark the `Slider` component's `React.forwardRef` call as pure so bundlers can remove the unused component and its engine from selective React imports. The public API and behavior are unchanged. A bundle regression test reproduces the old retention, checks pointer/boot imports without the slider, and verifies that a bundled slider still renders its controls and slides.
- Generate per-import minified/gzip byte measurements from the final dist, with the bundler version, target and compression settings documented. The measurement includes static shared chunks and excludes lazy debug chunks. These isolated fixtures are not application-route savings or new runtime benchmark results; the recorded 1.16.1 benchmark remains a historical snapshot.
- Normalize bundle output paths on Windows so size measurements count the same static chunks there; the focused bundle regression tests now run on Windows CI as well as Linux.

## 1.19.1 (2026-10-01)

### Changed

- The `add` CLI now prints the absolute destination of the copied component, so running it inside a frontend package in a monorepo makes the import target clear. The Cube windows gallery recipe and guide now put installation before the local React import, explain `--dir` relative to the current package, show the full component source for manual copying, and distinguish gallery recipes from package exports.

### Added

- fx gallery recipe: "Wall to floor fold" (`wall-floor-fold`), a copy-paste-only Pinned scenes effect, zero bytes in the core. Content scrolls down a wall and, at the fold line, continues onto a floor in perspective toward the viewer, driven entirely by the pin helper's `--sv-pin` (no scroll math of its own). The floor is a second, non-interactive copy of the same content, `aria-hidden` and `inert` together so it never reaches assistive tech or the tab order; the wall stays the only readable copy under no JS and under reduced motion, where the effect goes flat and the floor never renders. Geometry (depth, eye, floor angle, fold line, travel) is exposed as `--sv-fold-*` custom properties for tuning. Ships CSS, Tailwind and React panes plus an installed `WallFloorFold` component (`npx scrollvars add wall-floor-fold`).

### Fixed

- `ScrollVarsBoot` and responsive `Slider` no longer report hydration mismatches in Chromium when their script/style tags use strict-CSP nonces. Chromium deliberately hides active nonces from parsed DOM attributes, so React compared empty attributes with request nonces during hydration; both elements now mark that browser-normalized difference as expected while retaining the server nonce and pre-paint execution.
- A click owned by a scoped `toggles()` instance also syncs `aria-expanded` on every other live instance's matching triggers, including `<ScrollVarsBoot>`'s document scope; a sibling write that threw during that sync used to rethrow into the sibling's own lifecycle guard, which stopped it entirely (click listener removed, `MutationObserver` disconnected), for the rest of the page's life, over a write it never owned. The sibling failure now rolls back and reports once without stopping the sibling.
- `verdictForPage`'s coverage check counted DISTINCT rep ids without checking which ones they were, so rep ids `{0, 1, 99}` passed a declared `reps: 3` though repetition 2 never ran. It now requires the exact id set `0..reps-1`. Its per-delta check also rejected a rep only when EVERY delta was non-positive, so a rep with some zero or `NaN` deltas mixed among good ones passed (a zero dilutes the late-frame percentage; `NaN <= 0` is false). Every delta must now be finite and strictly positive. The published Chrome and Firefox lab captures still pass.
- `scrollvars/debug`'s HUD never calibrated its refresh interval on a page sustained below 50Hz from load (no delta ever fast enough to lock the reference), so it showed "calibrating… refresh unknown" forever, hiding the fps it could already measure. After 5 seconds with no qualifying delta the HUD now reports the measured fps with "refresh unverified" instead of staying silent; a healthy stream that calibrates normally is unaffected.
- A tracked child of `sv-auto` (`<section data-sv class="sv-auto"><p data-sv>`, itself `.sv` or `[data-sv]`) translated its own box while hidden, the same geometry feedback loop `sv-rise`'s self-tracked case already had fixed: the driver measures that same (now-moved) box to decide live/hidden, so near the exit line hiding moved it back INTO the activation band and flipped it live again, measured 65 spurious flips scrolling through and back. The child translate rule in `styles/core.css` and the compat fallback sheet now excludes a child that is itself tracked, the same `:where(:not(.sv):not([data-sv]))` shape `sv-rise` already used; it still fades via the opacity rule. Verifier fix: the exclusion added to the compat fallback sheet's RESET rules (the reduced-motion block, the `data-sv-motion="reduce"` twin, the `:focus-within` override) also stopped them matching the tracked child, which then kept its entrance opacity/transition instead of settling, measured opacity 0 with an 800ms transition under reduced motion. Each reset rule now also carries a plain (`:is()`/`:where()`-free, matching compat's own parse floor) unexcluded arm for the tracked case.
- A keyboard focus landing inside `sv-view-fade`/`sv-view-rise` near the entry edge of the viewport could stay transparent: Chrome scrolls a partly-visible focus target just into view and stops (it never centers one it already counts as visible), so the native scroll-driven entrance animation can still be mid-range when the focused box lands on screen, measured opacity 0.00 (`sv-view-fade`) and 0.28 (`sv-view-rise`), with and without JavaScript (WCAG 2.4.7/2.4.11). `styles/core.css`'s `@supports (animation-timeline: view())` block now adds a `:focus-within` override that stops the animation and restores full opacity, no translate.
- The home page's own hand-kept copy of the preset tier (`demo/index.html`) had no `:focus-within` override for its entrance presets, unlike the shipped `styles/core.css` since PR #112: its only focusable entrance child, the canvas code block in `#amb-section`, could be tabbed onto mid-fade while the section was not yet live. Added the same override to the page's own copy.
- A self-tracked `sv-rise` (the tracked element carries the class itself, not a child, either with no separate ancestor tracker or nested inside one) translated its own box while hidden, and the driver measures that same box to decide live/hidden: near the exit line, going hidden moved the box back INTO the activation band, which made it live again, in a loop (a regression introduced with the compound self-tracked entrance selectors, unreleased). Measured: 34 spurious flips scrolling down through the exit line and 30 back up for the plain case, 32 each way for a self-tracked `sv-rise` nested inside another tracker. The self-tracked case (either shape) now fades in place, with no translate, in `styles/core.css` and the compat fallback sheet; a plain (untracked) child of the tracker still gets the full rise.
- `styles/pin.css`'s no-JS resets for curtains, rail and deck are written with `:is()`: an engine with individual transforms but no `:is()` (Firefox 72-77, below the README floor of Firefox 78) drops the whole rule, so without JS a curtain stayed closed, a deck stayed stacked and a rail stayed offscreen. Plain-selector twins now sit next to each `:is()` rule.
- `scrollvars/debug`'s HUD calibrated its refresh interval from the fastest frame delta in its opening second with no check that the window was quiet: a page sustained at 30fps from load (every other vsync missed on a 60Hz display) calibrated its own drop rate as "healthy", reporting 0 dropped frames. The HUD now only locks its calibration onto a delta fast enough to prove 50Hz or better; a sustained slow opening keeps showing "calibrating" instead of a false-healthy reading.
- `styles/pin.css`'s page-switch layout release (stage release, curtains hidden, rail wrapped) is written behind `:where()`, so the whole rule dropped on an engine without `:where()`/`:is()` support, the same floor `scrollvars/compat`'s own fallback sheet targets. On that floor, switching to `data-sv-motion="reduce"` only reset the compat curtains' `transform` to `none`, their CLOSED position, so a curtain past its opening scroll position stayed covering the content for good and the stage never released. The compat fallback sheet now carries a `:where()`/`:is()`-free twin of the same three resets.
- A self-tracked `sv-spread-in` (the tracked element IS the spread, no separate ancestor) never matched the scrub and transition rules, which required a separate `.sv`/`[data-sv]` ancestor: the cards stayed stacked at the container's center indefinitely, overlapping each other. The scrub, transition and both reduced-motion rules now also match the compound (self) case, the same gap already closed for the other entrance presets.
- A focused marquee item could be translated well outside the visible strip, with no way to scroll it back into view: `:focus-within` only paused the track's slide, and a focused link mid-loop sat wherever the animation left it (WCAG 2.4.7/2.4.11). While the TRACK itself now holds focus, `.sv-marquee-track:focus-within` presents it statically and wrapped with the duplicate hidden, mirrored in the gallery's CSS pane. Keyed on the track, not `.sv-marquee`, so focusing a controlled marquee's pause button does not reflow the strip.
- A focused control inside `.sv-drift` kept the scroll fade: a fully visible link at the viewport edge read opacity 0.51, never hidden but still dim under WCAG 2.4.7. `.sv-drift:focus-within` now restores full opacity, in `styles/core.css` and the compat fallback sheet.
- `CaseStudyRail`'s fallback below the `cqw` container-query floor turned the stage into a horizontal scroller with no `tabindex` and non-interactive cards, leaving every card past the first unreachable by keyboard in engines that do not auto-focus scrollers (WCAG 2.1.1). It now wraps the rail in flow instead, like the no-JS layout.
- The document scope's late-marquee `MutationObserver` pre-check, `hasUnleasedTrack()`, gated the WHOLE callback body, so on a page with NO marquee anywhere a plain `[data-sv-toggle]` trigger mounted after boot (an SPA menu button, the common route-mount shape) never ran `bootTrigger` at all: it carried no `aria-expanded` and no initial `--sv-state` until its own first click (WCAG 4.1.2, a regression introduced by the marquee-free perf pass that first added this pre-check). The callback now also runs when the batch's own `addedNodes` contain a new trigger, marquee or not, at the cost of one `matches()`/`querySelector()` pair per added element (a batch that adds no element at all, text or attribute-only, still costs nothing). Measured with `test/toggles-mutation-cost.mjs`, a 500-element unrelated insert on a marquee-free page: before, 0ms (skipped entirely, at the cost of the bug above); after, 0.138-0.172ms median over 7 runs on a quiet machine (load 0.9/1.4/1.8, well under the load-check rule's ceiling of 6).
- The compound (self-tracked) entrance selectors added for `sv-rise`, `sv-fade`, `sv-slide-l` and `sv-slide-r` matched the entrance rules but not either reduced-motion override (the OS media query or the `data-sv-motion="reduce"` twin), in `styles/core.css` and the compat fallback sheet: a self-tracked entrance (the tracked element IS the preset, no separate ancestor) stayed hidden and then animated a full travel distance under reduce, a regression against README's and AGENTS.md's "every preset sheet honors reduced motion". Both overrides, in both sheets, now also match the compound case.
- `sv-split-rise` never matched a self-tracked element (`<h2 data-sv data-sv-split class="sv-split-rise">`, the tracked element IS the split preset): its entrance rule required a separate ancestor `.sv`/`[data-sv]`, so the spans stayed at opacity 1, fail-visible but never actually animating. The entrance rule and both reduced-motion overrides now also match the compound case.
- The staggered-reveal and split-reveal gallery CSS panes pasted the entrance hide, transition and reduced-motion rules from `styles/core.css` but not its `:focus-within` override: a reader who used only the pasted CSS left a focused entrance child (or a focused split heading) waiting out its stagger delay and transition instead of appearing at once (WCAG 2.4.7). Both panes now carry the override, placed after the reduce blocks.
- `demo/bench/harness/a11y-tree-gate.mjs`'s negative counterpart for the slider's status region read the status node's own accessible `name`, which Chrome always reports empty (the actual text sits on a child), so the check passed whether or not the announced text changed and proved nothing about the detector. Both the real check and its negative and positive counterparts now share one `statusSays()` helper that walks the whole status subtree, like the real check does.
- `toggles()`'s `resolve()` looked up a `data-sv-target` class selector against the WHOLE scope: under a document scope (`<ScrollVarsBoot>`, the css pane's own `toggles()`), two controlled marquees sharing the class selector `.sv-marquee-track` both resolved to the FIRST track on the page, so a second marquee's own pause button stayed hidden and, if clicked programmatically, paused the first marquee instead. A trigger inside a `.sv-marquee` wrapper now resolves its target inside that wrapper first, falling back to the scope for every other trigger.
- The document scope's late-marquee `MutationObserver` pre-check asked only "is any live track unleased", so a track inserted in one task and its pause button in a LATER one (two separate mutation batches) found every track already leased on the button's own batch and returned early: the track never got `sv-ui`, and the button stayed hidden behind ui.css's `.sv-marquee-track.sv-ui + .sv-marquee-pause` guard forever. The pre-check now also passes when a live pause button's resolved track lacks `sv-ui`, still zero iterations on a marquee-free page.
- The docs page's GSAP interop example used `gsap.ticker.add` to read `--sv-t` directly with `|| 0`, the same fail-hidden pattern already removed from the gallery's tailwind pane: on release the driver clears the property, sending a `.from({ opacity: 0 })` timeline back to its hidden start, with no reduced-motion guard, and reduced motion keeps `--sv-t` updating so GSAP keeps moving too. The snippet is dropped from the paragraph, which already links the guarded `gsap-scrub` recipe two lines below.
- `useScenes`/`useSlider` "re-render only on a discrete index change" was repeated at AGENTS.md's own "why the numbers come out this way" section and twice in `docs/guide.md`, missed by the gate that only checked AGENTS.md's earlier sentence (already corrected). All three now say what is actually true: re-render only on discrete events (index, status, flow, motion).
- AGENTS.md and the guide said any CSS mention of `--sv-page`/`--sv-v`, "a `var()` read, a declaration, even a comment", turns publishing on; the driver excludes its own inline writes on `<html>` from that check (only a `var()` read there counts, never a bare declaration), or a rescan would read its own output as a consumer and never unpublish. Both surfaces now name the exception.
- The React `<Slider>` announced nothing when a person navigated to another slide: its rail carried `aria-live="polite"`, but navigation only ever wrote `scrollLeft`, a class and a custom property, none of which is text a live region reads out. A visually hidden `role="status"` region now sits outside the rail, reading "Slide N of M" after `next`/`prev`/`goTo`/a dot click, silent (`aria-live="off"`) while autoplay rotates on its own; the rail no longer carries `aria-live` itself. The vanilla `slider()` has no live region of its own and is unchanged.
- The React `<Slider>`'s rail rendered with no `tabIndex`, so before hydration, without JS, or after a failed enhancement, the server-rendered carousel was keyboard-reachable only in engines that focus a scrollable region themselves; Safari does not, leaving slides past the first unreachable. The rail now renders `tabIndex={0}` directly. `slider()` already leaves an existing `tabindex` attribute alone, so hydration does not double-write it.
- `styles/core.css`'s focus override for a focused Split heading targeted `.sv-split-rise > span:focus-within`, a selector that can never match: the word spans are `aria-hidden` and never hold focus, only their container does. Focusing a `<Split tabIndex>` heading left every span at opacity 0. Fixed to `.sv-split-rise:focus-within > span`, keying off the container's own `:focus-within`.
- Every entrance preset selector (base hide, translate and the focus-visibility override, in `styles/core.css` and the compat fallback sheet) required the preset class to be a DESCENDANT of `.sv`/`[data-sv]`: a self-tracked element, where the tracked element IS the preset (`<pre class="sv sv-rise" tabindex="0">`, or `data-sv` and `sv-rise` on the same node), never matched at all, so it stayed at its unstarted, partially-faded state indefinitely, focus included. Every rule now also matches the compound (self) case.
- `toggles()`'s document-scope `MutationObserver` callback wrote late boots (a route-mounted marquee's track and pause button) into the same standing `transaction`/`siblingWrites` journals a click uses, only ever reset by the NEXT click anywhere in the document. Under `<ScrollVarsBoot>`, a page navigated without a click (programmatic routing, popstate) kept every late-booted write strongly referenced indefinitely, and a throw partway through one delivery's batch left the earlier writes of that same batch in place, unlike setup and click, which both roll back. The callback now gets its own fresh journals per delivery, rolls back on a throw and resets in `finally`, exactly like `onClick`.
- A `[data-sv-toggle]` trigger mounted before its `data-sv-target` ever resolved (a menu button rendered ahead of the nav it controls, a common SPA mount order) never got `aria-expanded` or `--sv-state` even once the target arrived in a later mutation batch: `bootTrigger()` just returns on an unresolved target, and only newly inserted triggers were ever retried. The document scope now keeps a per-instance set of unresolved triggers, retried on every later batch until each one resolves (dropped once it does, or once it disconnects), so a trigger and its target arriving in two separate batches now agrees with the class from the first frame, matching the one-batch case already fixed. The deliberate setup-time skip (marked `sv-ui` only on first click when neither the trigger nor a `MutationObserver` can retry it) is unchanged. Retrying every pending trigger inside ONE shared transaction meant a single trigger's write throwing rolled back a SIBLING pending trigger's own successful resolution in the same delivery too, and stopped the retry loop outright, so any trigger later in iteration order never even got tried. Each pending trigger now retries in its own transaction: one failure cannot touch a sibling's writes or block the rest of the batch. A trigger whose target resolves but whose write keeps throwing (an already-resolved target will not heal itself; there is no backoff machinery) is dropped from the pending set and reported once, instead of being retried, and reported again, on every later mutation forever.
- Both `toggles()`'s document-scope `MutationObserver` callback and its click handler rolled back a failing write and then rethrew, which `life.guard` turns into a permanent `stop()` of the WHOLE document scope over one bad write: the click listener is removed and the `MutationObserver` disconnects for the rest of the page's life, contradicting the module's own "the document scope never stops" design. Both now report the error directly instead of rethrowing, so a later click on a working trigger, or a later-mounted trigger, still works.
- Removing an already-offscreen marquee's whole subtree (an SPA route swap taking its page away) got no `IntersectionObserver` delivery for it (an already non-intersecting target reports no further transition on removal) and never opened the document scope's `MutationObserver` pre-check either (no unleased track, no new trigger, no pending trigger): the lease, the shared `IntersectionObserver` and the `visibilitychange` listener sat alive over a detached subtree until the user's next click, tab switch or marquee registration. A removal-only batch (any mutation record whose `removedNodes` is non-empty) now sweeps every leased marquee for detachment itself, before the pre-check can bail; a page with no leased marquee at all pays nothing extra.
- `toggles()`'s document-scope `MutationObserver` callback booted every NEW trigger of one delivery inside one shared transaction: a write throwing on one trigger rolled back a sibling trigger's already-written state too, and stopped the loop outright, so any trigger later in the batch's iteration order never even got tried, all reading no `aria-expanded`/`--sv-state` even though only one of them ever failed. Each new trigger of a delivery now boots in its own transaction, the same isolation the pending-trigger retry already had: one failing write is dropped and reported once, and every sibling trigger of the same batch still boots normally.
- The shared marquee `IntersectionObserver` and `visibilitychange` callbacks ran with no failure boundary of their own: a throw from a patched or exotic element's `classList` (the only way either callback can throw at all) would have stopped the batch before settling any marquee after it. Both are now wrapped per marquee, so a throwing track is reported once and every other marquee in the same batch still settles, matching AGENTS.md's "one bad toggle should not disable every other toggle in the same scope" for these two shared, instance-independent callbacks. `sweepDetachedMarquees()`, called from both of those callbacks and from the document scope's `MutationObserver` (its own C2 removal sweep above), had the same gap one level deeper: a throw pruning one detached track reached the `MutationObserver`'s `life.guard` and stopped the WHOLE document scope, or skipped every OTHER entry of the same `IntersectionObserver` batch. Each pruned track is now guarded inside the sweep itself.

### Docs

- AGENTS.md said any custom `--sv-r` consumer, not only the ready-made `sv-range-rise` preset, settles to its end state below the `sv-range` floor, contradicting the more precise Sequenced scrub paragraph a few lines above (a custom consumer falls back to its own initial or inherited value instead). The floor sentence now names `sv-range-rise` and points to that paragraph.
- `docs/guide.md` and the generated docs page still called the slider's TRACK the live region after the rail lost `aria-live` in favor of a separate `role="status"` region outside it. Both now describe the status region instead.
- AGENTS.md and `docs/guide.md` said runtime failures "stop only that instance" across every stateful module, which used to be true of `toggles()` too (a throwing click killed its listener for good, on any instance, and the document scope's `MutationObserver` for late-boot as well). Both now carve out the exception described above, corrected in a follow-up pass to name every `toggles()` instance, not only the document scope: a probe against a scoped root's own click handler found the same report-and-continue behavior there.

## 1.19.0 (2026-09-27)

### Added

- Author-side evidence for the outstanding "path to 8/10" review items (docs and CI only, no public surface change): an accessibility-tree proxy gate (`demo/bench/harness/a11y-tree-gate.mjs`, wired into `npm run test:e2e`) that reads Chrome's accessibility tree over the Slider, Modal, Accordion, toggles, split text, Scenes fallback and the Marquee pause control, every check proved able to fail on its own deliberately broken analog; a dated, fill-in VoiceOver checklist for the author's manual macOS and iOS pass (`docs/guide.md`, Accessibility section); and a physical-device frame budget matrix (`docs/guide.md`, stamped from `demo/bench/lab/results/published/*.json` by the new `scripts/lab-tables.mjs`) with a frame budget declared before any number is read (`LAB_FRAME_BUDGET`, `scripts/docs-data.mjs`), linked from AGENTS.md's performance paragraph. Desktop Chrome and Firefox runs are committed; Safari desktop and iPhone Safari are explicit pending placeholders, not fabricated rows. The lab (`demo/bench/lab/run-drive.js`) now calibrates each run's vsync interval independently (an idle rAF window before the scroll starts), instead of deriving it from the same scroll frames the budget then judges; the two committed runs predate that field and are labelled self-calibrated in the table rather than silently upgraded. The table also reports each page's worst rep alongside its median.
- A served accessibility checklist fixture, `/a11y/` (`demo/a11y/index.html`, built by the new `scripts/a11y-build.mjs` in `npm run demo:sync`): the real kit (Slider, a real `<dialog>` Modal, a `sv-pop` disclosure, Accordion, split text, a real `<Scenes>` in its stacked fallback, a controlled Marquee and RotatingWords with its pause control), mounted against the shipped `styles.css`, deployed at that path. The VoiceOver checklist in `docs/guide.md` now points every row at this page instead of gallery previews that could never pass it (a vanilla slider with no carousel semantics, a `<dialog>` no page shipped, an Accordion no row named). `a11y-tree-gate.mjs` runs against the same page: shipped CSS loaded, the Slider's announced position checked after activating "next", and a real `<Scenes>` fallback checked under reduced motion, each with its own negative counterpart (docs and CI only, no public surface change).
- `scrollvars/debug`'s `debug()` gained three new options, all off by default except `hud` and `lint`, which is exactly what shipped before minus nothing (the tracked-element panel stays as it was): `hud` (default true) mounts a performance HUD (FPS, dropped and late frames against the measured refresh interval, the worst frame of the last 5 seconds, and, where `PerformanceObserver` supports `long-animation-frame` (Chrome), blocking time with the top script attribution; elsewhere it says so instead of showing zeros); `markers` (default false) draws, for every tracked element, ScrollTrigger-style start/end lines for its travel and, for pinned elements, its pin stretch, labelled and positioned in page coordinates so they scroll with the page and recompute on resize; `lint` (default true) scans same-origin stylesheets once for a `--sv-*` read landing in a property that cannot be composited (width, height, the inset family, margin, padding, font-size, filter, backdrop-filter, box-shadow, background-position, clip-path, mask), directly or through one level of custom-property indirection, and reports selector, property and why, once, in the overlay and the console; a cross-origin sheet is reported as skipped, never silently ignored. `DebugOptions` gained `hud`, `markers` and `lint` (minor, additive).
- A `.sv-marquee-track` now pauses itself off screen and with the tab in the background (one shared IntersectionObserver and one shared visibilitychange listener across a page's toggles() scopes and marquees), and resumes on return, independent of the user's own pause button. New class: `.sv-marquee-offscreen` (public, additive).
- The stamped `scrollvars/react` gzip size is now measured with esbuild's real code splitting, entry chunk only: the dev-only `?sv-debug` overlay (loaded on demand, never on a page that does not ask for it) is no longer counted toward it. The published number drops from its pre-split figure for this reason, a measurement fix, not because the React layer shrank; see the stamped number in `docs/guide.md` for the current one and `scripts/docs-data.mjs`'s `measureSizes()` for the split build.
- A public, installable skill for AI coding agents: `skills/scrollvars/SKILL.md`, generated in `npm run demo:sync` from AGENTS.md's own text (mental model, the fail-visible guard, imports, the fx gallery, the performance rules), so it can never hand-drift from the guide it wraps. Shipped in the npm package (`files`). `npx scrollvars skill [--global] [--force]` installs it for Claude Code (`.claude/skills/scrollvars`) and Codex (`.agents/skills/scrollvars`), matching the installed package version; refuses to overwrite a locally modified copy without `--force`, idempotent otherwise. Also installable with the open `npx skills add aduptive/scrollvars` convention (reads a repo's `skills/<name>/SKILL.md`). The site hero and the top of the README now carry the one install command. The release gate fails if the packed tarball lacks the skill or its version does not match `package.json`.
- `toggles()`'s document scope now acquires a `.sv-marquee-controlled` mounted after it starts (a route-mounted marquee under a live `<ScrollVarsBoot>`, most of the time): a `MutationObserver`, owned by that one long-lived instance only, wires the late track and its pause button through the same lease-counted paths setup uses, released on `stop()`. Measured at about 0.16ms of script per mutated frame on a page appending one such wrapper every frame for 120 frames (`demo/bench/harness/toggles-mutation-cost.mjs`); a custom-root scope (a `<Marquee>` component's own `toggles(node)`) gets no such observer, since it already owns its one track for its whole lifetime. A page with no marquee at all pays an O(1) check per mutation batch instead of a subtree scan: the callback walks the live `document.getElementsByClassName('sv-marquee-track')` collection (empty, zero iterations, on a marquee-free page) and only scans an inserted subtree when it holds a track not already leased, an identity check rather than a bare count, so a route swap that removes one marquee and inserts another in the same mutation batch still wires the new one even though the count never changed.

### Fixed

- The physical-device lab table (`scripts/lab-tables.mjs`) judged the MEDIAN repetition against a calibration averaged across the whole file, so one bad rep, an errored rep or a missing page could hide behind the others. Every repetition is now judged against its own `vsyncMs` (falling back per-rep to that rep's own median when the field predates calibration), and a page needs full coverage (every declared repetition present, each with usable deltas and its own animated check passing) to pass at all. `verdictForPage` is exported and covered by `test/lab-tables.test.mjs`. The table still displays the median and worst rep.
- `verdictForPage`'s coverage check counted `runs.length` against the declared repetition count, so rep ids `[0, 0, 1]` (the same repetition captured twice, the third never run) read as full coverage. It now counts DISTINCT rep ids. A rep whose deltas are all zero or negative, whose own `vsyncMs` is non-positive, or whose self-calibrated median vsync comes out non-positive now fails outright as unusable instead of passing against a degenerate (zero or negative) budget.
- The a11y-tree checklist proxy's "Slider announces the new current slide after activating next" check was a false positive: every slide's static "N of 3" label is in the tree from the first render, so the check passed whether or not "next" did anything. It now asserts the real per-activation signal, the active dot's `aria-current` moving from slide 1 to slide 2, proved able to fail against a no-op "next" counterpart. The VoiceOver checklist row for the Slider now names this signal explicitly.
- The `gsap-scrub` gallery recipe's `requires.min` (1.17.0) let older releases install a component that passes `onStatus` to `<Track>`, an API that shipped in 1.18.0; on an older install the prop silently lands on the DOM and the fail-visible settle never fires. Raised to 1.18.0. The `marquee` recipe's `requires.min` (1.9.0) had the same shape for the pause button, which shipped in 1.14.0; raised to 1.14.0.
- The GSAP tailwind pane's "zero-wrapper" alternative used `gsap.ticker.add` to read `--sv-pin` directly: on release the driver clears the property, `parseFloat('')` is `NaN`, and the `|| 0` fallback sends a `.from({ opacity: 0 })` timeline back to its hidden start, with no reduced-motion guard. Dropped in favor of the css pane's guarded `track()`/`onPin` callback, which already covers zero-wrapper pages.
- The installed RotatingWords pause button flipped its label ("Pause rotation" / "Resume rotation") while also carrying `aria-pressed`, which reads as a self-contradiction to a screen reader ("Resume rotation, toggle button, selected"). The label is now fixed ("Pause rotation"), matching the gallery preview, the kit's `<Marquee>` pause and the WAI-ARIA APG toggle-button pattern.
- A keyboard focus landing inside an entrance preset (`sv-rise`, `sv-fade`,
  `sv-slide-l`, `sv-slide-r`, `sv-auto`, `sv-split-rise`) stayed invisible
  until its stagger delay and transition ran, up to 1.7s for the eleventh
  staggered child: a WCAG 2.4.7 failure, since a focus indicator on an
  opacity-0 element is not visible. `styles/core.css` (and the compat
  fallback sheet) now carry a `:focus-within` override, placed after the
  entrance rules, that settles a focused entrance item or one that contains
  the focused control to fully visible at once, no delay, no transition.
- `docs/guide.md`'s `useScenes` hook example keyed its stacked branch's
  current scene by `i === scene`, the exact identity bug `<Scenes>` itself
  was fixed to avoid: the example now uses a plain index key throughout.
- `AGENTS.md` claimed React "never re-renders per frame, only on discrete
  index changes"; completion, attachment, flow and motion changes also
  re-render, none of them an index change. It also claimed the scoped
  clocks sheet "can only make [a page] faster where it is understood",
  contradicted by its own measurements two paragraphs above (a flat
  profile 4 percent slower, several gallery pages slower): both sentences
  now state only what is actually guaranteed. The guide carried the same
  scoped clocks sentence and is fixed the same way.
- The VoiceOver checklist (`docs/guide.md`) said RotatingWords had "no
  pause control yet"; the pause button shipped in both the preview and the
  installed component. The checklist now steps through it instead.
- `<Scenes>`'s stacked branch (reduced motion, a failed attach, a flowed
  stage) keyed its "current" fragment by `i === scene`: the driver keeps
  reporting scene changes while stacked, so React moved that key between
  scenes on every one, destroying a focused input or a playing video in a
  scene the reader had not left. The stacked branch now freezes which
  index carries "current" the moment the component goes stacked; no key
  in that branch depends on the live scene, and the active/stacked switch
  keeps the guarantee it already had (PR #92).
- `<Track>` and `<Scenes>` dropped `sv-live` on a completed `once` reveal
  the next time their own `className` prop changed: the driver settles a
  completed entry outside its tracked set and stops re-asserting the
  class, so a React className rewrite (which replaces the whole `class`
  attribute) wiped it. Both components now carry `sv-live` in their own
  rendered className once `onStatus('completed')` fires, resetting on the
  next `'attaching'`. A bare `useTrack` consumer has to do the same on its
  own element (documented in AGENTS.md).
- Every GSAP scrub recipe (the installed `GsapScrub` component, the
  rendered gallery preview, and the copy-paste CSS/React panes) only ever
  moved its timeline through `onPin`: under reduced motion, or once the
  tracker failed or was released, `onPin` never fires again, so a
  `.from({ opacity: 0 })` timeline stayed stuck at progress 0 forever, a
  fail-hidden. Every surface now settles to progress 1 on the effective
  motion preference and on a failed/released tracker, scrubbing only
  while attached (`requires.min` bumped to 1.17.0 for the `onMotionChange`
  import).
- `RotatingWords` rotated forever with no way to stop it (WCAG 2.2.2), and
  the marquee gallery's preview and CSS/Tailwind panes taught the bare,
  uncontrolled `.sv-marquee` shape with no pause button. `RotatingWords`
  now renders a keyboard-operable, persistent `aria-pressed` pause button;
  the marquee preview and panes render the controlled markup
  (`sv-marquee-controlled` plus `sv-marquee-pause`), matching the `<Marquee>`
  component. `hero-cinematic`'s own CSS/Tailwind panes (which also embed a
  bare marquee strip) got the same button.
- A `<link rel="stylesheet">` whose own `error` event the driver observed
  (a 404, a reset connection, an unreachable host, while the sheet was
  still pending) kept publishing `--sv-page`/`--sv-v` on `<html>` forever
  for every page with no consumer at all: `sheetReadsPageOutputs()`'s
  catch (an unreadable sheet is uncertainty, so it defaults to "assume it
  reads them") could not distinguish that observed failure from an opaque
  cross-origin sheet, so `found` latched true and the watch that would
  notice a later, corrected href never installed. An observed failure now
  turns outputs off; correcting the link's `href`/`rel`/`media`/`disabled`
  is judged again. A batch that corrects two links at once now forgets
  both, not only the first (the attribute-mutation watcher broke out of
  its records loop early).
  Deliberately NOT fixed the same way: a link whose sheet throws without
  the driver ever observing its `error` (measured in real Chrome, the
  sheet can already be a throwing object by the first frame that asks,
  before any listener attaches) keeps the conservative default, publishing
  for the rest of the session. A same-origin `<link>` can throw on
  `cssRules` for a reason that is not a load failure at all: a 302
  redirect to a cross-origin CSS file keeps a same-origin `href`, fires
  `load`, and still throws (CORS opacity on the redirected response),
  common on reverse-proxied or versioned CDN setups; an earlier version of
  this fix read any same-origin throw as a failure and broke exactly that
  case. Wrongly publishing costs a per-frame write; wrongly silencing
  breaks rendering, so the conservative default wins whenever the driver
  itself never saw the failure.
- The gallery's GSAP React recipe (`gsap-scrub`) timed a `.from('.stage > *', ...)`
  selector against markup that renders `.sv-stage`: pasted as shown, the
  timeline animated nothing. It now scopes to the stage's own ref.
- AGENTS.md's `--sv-r` paragraph, `styles/pin.css`'s two comments and
  `docs/guide.md`'s explanation were wrong in two of the four engine
  cells (a round-1 fix that reworded the claim without checking each
  cell against the two floors, calc() division and `@property`): Chrome
  85-111 has `@property` but not the division, so `--sv-r` is registered
  and settles at its initial value, 1; Firefox 112-127 has the division
  but not `@property`, so `--sv-r` is unregistered and simply keeps
  animating, the same as full support; only below BOTH floors (Firefox
  under 112, Safari under 16.4) is the consumer's own declaration invalid
  at computed-value time, falling back to its own initial or inherited
  value, not to 1. Worded to carry the matrix in all four copies. The
  `--sv-page`/`--sv-v` VARS row also read as ambiguous between
  "(tracked and CSS) or setPageOutputs" and "tracked and (CSS or
  setPageOutputs)" (the latter is what the driver does); reworded to
  read one way only.
- The `data-sv-fit` overflow check compared the fit node's own height
  against the whole stage `clientHeight`, never counting where the fit
  node itself starts inside the stage (padding-top, a heading above it,
  its own margin): a box that fit its own height could still clip against
  `overflow: hidden` (WCAG 1.4.4/1.4.10). The check now adds the fit
  node's `offsetTop` (an offset-chain read, not a bounding rect) when the
  stage is its `offsetParent`, falling back to the old comparison for a
  consumer stage without the preset.
- A marquee track detached from the document while already off screen got
  no further IntersectionObserver delivery (no intersection transition to
  report), so the per-target prune in the IO callback never ran for it: it
  stayed in the lease bookkeeping, observed, until the next tab switch.
  The lease map is now swept for detached tracks on every IO delivery
  batch, every new marquee registration and the document scope's own
  click handler, no `MutationObserver` needed.
- Without native `inert` (Firefox 78-111, Safari 14.1-15.4, inside the
  README floor), `StickySteps` still applied `inert`/`aria-hidden` to
  inactive shots, and the pause-controlled `Marquee`'s duplicate strip
  still turned on: without real support the attribute is inert in name
  only, so links inside an invisible shot or an animated duplicate stayed
  in the tab order (WCAG 2.4.7/2.4.11). Both surfaces gate on
  `'inert' in HTMLElement.prototype` now: `StickySteps` (installed and
  gallery copies) stays static with every shot reachable, and `toggles()`
  never marks a controlled marquee track `sv-ui`, so `styles/ui.css` keeps
  its strip static and the duplicate hidden.
- `toggles()`'s marquee release closure captured its track directly; a
  Boot-owned document scope that never stops kept that closure, and the
  detached track it held, for the app's life. It now goes through the same
  `weakRef()` helper the markers use, falling back the same way where
  `WeakRef` is unavailable.
- `useScenes` carried its `flowed` flag from one lease into the next: a
  replacement target (any `useTrack` dependency change) with no
  `[data-sv-fit]` child and no flowed ancestor never gets a re-announced
  `onFlow`, so a lease that once overflowed stayed `active: false`
  forever, even once it fit cleanly. `flowed` now resets on
  `onStatus('attaching')`, before the new lease's own `onFlow` can land.
- Below the individual-transform floor with no `compat()`, only a stage
  carrying a static deck was released to flow: a plain pinned stage (the
  common Scenes/Sections shape) kept reporting `active` while pin.css
  released the stage underneath it anyway, so the pin span collapsed to
  about one pixel and `<Scenes>` rendered only its current scene, the
  rest unreachable. Every stage below the floor with no `compat()` now
  flows too (`data-sv-flow`, `onFlow(true)`), matching what a deck-bearing
  stage already did.
- `<Scenes>` rendered only the current scene outside the enhanced, pinned
  state: on the server, without JS, after a failed attach, and after a
  fit-to-flow release, the scene index sat at 0 (or jumped straight to it
  after a helper-pinned stage collapsed its span), so scenes 1..N-1 were
  never in the DOM, the same content-unreachable shape the reduced-motion
  fix above closed for one cause and left open for the others. `useScenes`
  now hands back `active` (attached, not flowed, not reduced), and
  `<Scenes>` renders every scene, stacked, whenever it is not active,
  matching what it already did for reduced motion; server and first
  client render agree, since `active` starts false. `ScenesState` gained
  `active` (minor, additive). The guide's `Story` example and four doc
  sentences now use it instead of `reduced` alone.
- Under reduced motion, a helper-pinned `<Scenes count={4}>` collapsed its
  pin span to about one pixel, so `--sv-scene`/`onScene` jumped from 0 to
  N-1 in one pixel and the guide's own example (`Slide {scene + 1}`) never
  rendered the middle scenes. `useScenes`/`<Scenes>` now hand back a
  `reduced` flag, and `<Scenes>` calls its render function once per scene
  and stacks the result under reduced motion, so every scene stays
  reachable. `ScenesState` gained `reduced` (minor, additive). Four doc
  sentences that claimed the pin/scene clocks keep scrubbing under reduced
  motion now say a helper-pinned section returns to flow and its pin/scene
  clocks finish at once, matching what the driver already did.
- Marquee's offscreen watch had first-registrant-owns semantics: a second
  `toggles()` scope registering the same `.sv-marquee-track` (the common
  `<ScrollVarsBoot>` + `<Marquee>` shape) never got its own release wired,
  so stopping the scope that registered first stranded the track observed
  forever (a leak on every client-side navigation away from its page), and
  stopping the OTHER scope stripped the offscreen class and observer from
  a track the first scope still needed. Fixed with a per-track lease
  count: unobserve, delete and remove the class only when the last lease
  goes, and a track that has left the document is pruned (unobserved, its
  lease dropped) on its next IntersectionObserver delivery or visibility
  pass, so a document-wide scope that never stops cannot keep a removed
  track leased forever. A lease also carries a generation number, so a
  track that is detached, pruned and later reattached under a new scope
  cannot have its fresh lease released by a stale stop() from before
  the prune.
- Emptying a populated slider (every slide removed) left `state().active`
  pointing at a detached node with `count: 0`, so repopulating at the same
  index fired no `onSlide`. An empty list now resets `active` to `-1` and
  releases the owned `--sv-slide`.
- A disabled `<link rel="stylesheet">` enabled later never turned page
  outputs on: the consumer watch only observed `childList`, so an attribute
  change (`disabled` flipping) queued no rescan. It now also observes
  `disabled`/`rel`/`href`/`media` on every `<link>`.
- A tracker under a hidden custom scroll root (`display: none`) wrote
  `--sv-t: NaN` and passed `NaN` to `onTravel`; `computeTravel` now holds at
  0 when the viewport and element height are both zero, the same guard
  `computeView` and `pinSpan` already had.
- Debug markers ignored the pinned stage's normal-flow origin (a heading or
  padding before the stage), so both lines were off by exactly that much;
  they also picked up a nested tracker's stage instead of their own.
  `pinLines` takes the origin now, and the overlay resolves the owned stage
  the same way the driver does.
- The debug HUD calibrated its refresh rate from the frames it was judging,
  so a sustained half-rate page (every other vsync) read as a 30Hz display
  with 0 dropped frames. It now calibrates once, over a quiet opening
  window, and judges every later frame against that frozen interval; a
  frame spanning a backgrounded tab is neither a calibration sample nor a
  dropped one.
- The debug panel used `backdrop-filter`, which re-rasters every scroll
  frame and would have the HUD in the same document measure that cost as
  the page's own. Solid background now, no filter.
- `npx scrollvars add` with no slug fetched the effect registry before
  printing usage, so an offline run reported a registry error instead of
  the command's own help text.
- The fx gallery's React panes had six uncast `style={{ '--sv-*': ... }}`
  object literals (`TS2353` in a strict consumer's `tsc`, the same shape a
  previous release already fixed in the installed components); all cast to
  `React.CSSProperties` now, and a new gate checks every pane.
- The marquee CSS pane's reduced-motion block only stopped the animation;
  the real stylesheet also releases the strip's width and hides the
  duplicate copy, or a pasted marquee under reduce keeps clipping content
  past the viewport edge. The pane now matches, and the pause covers
  `:focus-within` like the real sheet.
- The coverflow slider's CSS and Tailwind panes used `abs()` (Chrome 104,
  above this project's stated floor); rewritten to the installed
  component's `max(x, -1 * x)` equivalent, and the Tailwind pane gained a
  reduced-motion guard it never had.
- The docs page's browser-support text still described a rail that stayed
  unwrapped below the transform floor and a deck that clipped past the
  stage with `compat()` installed; both were fixed in earlier releases
  (the rail wraps in `pin.css`'s own net, a deck-holding stage returns to
  flow regardless of `compat()`). Rewritten from the current code.
- Three doc inaccuracies: "even a comment counts" toward page-output
  detection is true only for an inline `<style>`, never a linked sheet;
  `--sv-t`'s claimed parity with native `view()` did not account for its
  zero inset; the browser-floor line omitted `sv-range` and `sv-acts`
  (both settle to their end state below the floor, nothing is lost).
- "`npx scrollvars skill` installs this file" was imprecise: it installs a
  skill built from the file, which points the agent back here.
- Perf phase 1, stop work nobody sees. React `<Slider autoplay>` already skipped a tick while off screen or with the tab hidden; the `setInterval` itself now stops there too and restarts on return, instead of waking up every interval on a background tab full of never-visible carousels. A correctness fix ("do no work off screen"), not a headline win: measured on the perf lab's `long.html` (marquee and slider both present) the frame cadence is unchanged on this machine, as expected for background work that was already cheap to skip; the actual stopping is what a new test asserts directly, an exact `setInterval`/`clearInterval` count.
- The site hero's "use it with your AI agent" pill wrapped `npx` onto its own line at narrow widths. The command now sits in its own `white-space: nowrap` span while the label around it wraps freely; verified at 320, 375 and 1280 with real mobile/desktop viewport emulation (no horizontal overflow, the command never breaks across lines).
- AGENTS.md's performance rule 6 recommended `will-change: translate` on every scroll-moved element, which our own published benchmark contradicts: hinting `will-change: transform, opacity` on the driver's own moving elements raised main-profile task time about 9% and gave no measurable gain on the deep-50 profile (`demo/bench/harness/README.md`, main-style-css screen), and a separate screen rejected it outright (`demo/bench/harness/HYPOTHESES.md`). Rewritten from the measured facts: do not add `will-change` by default; add it only when a profile shows a specific moving element repainting every frame, and remove it once the motion stops. The skill and llms.txt follow, generated from the same text.

## 1.18.1 (2026-09-25)

### Fixed

- A failed scan (a throwing ResizeObserver at init, an attachment that throws mid-sweep, or a later mutation batch whose attach throws) left its unprocessed `[data-sv]` content hidden at opacity 0 forever whenever `html.sv-on` was up: the marker-only settlement (`data-sv-off`) never lifted `--sv-live`, and only `--sv-live` gates the entrance presets. `settleUntracked()` and the failed-init branch of `attach()` now write the inline `--sv-live: 1` too, deferred the same way the `data-sv-off` marker already is (once nothing tracked is left inside the element); `releaseEntry()` keeps its own immediate, unconditional write, since a released element's own entrance never depended on a nested tracker's state. A scan that has failed also keeps its mutation observer connected, in settle-only mode, so a `[data-sv]` node inserted afterward (a CMS block, a route change) settles visible too instead of staying hidden with no observer left to release it.
- `clearReleased()` cleared the pending `data-sv-off` marker on retrack but not the pending `--sv-live` lift the fix above added: an element settled through `settleUntracked()` while a nested tracker was still live, then retracked before that descendant released, stayed queued for the lift forever, a harmless (same value, `--sv-live: 1`) but permanent memory retention leak. `clearReleased()` now drops the element itself, never an ancestor, from that set too.
- fx gallery: `CoverflowSlider` wrapped every top-level child in its own `<Slide>`, so a fragment of slides collapsed into one and a child that was already a `<Slide span={2}>` had its `--sv-span` land on an inner div the column rule never sizes by. Fragments are opened recursively now (keyed the same way the kit's own rail does), and an authored `Slide` gets `cf-slide` added to it instead of a second wrapper.
- An outer pinned section that fell back to document flow (its fit box overflowed) released its own `.sv-stage` but left a nested tracker's tall wrapper and static, clipping stage behind: `[data-sv-flow] .sv-stage` (styles/pin.css) releases every descendant stage, not only the outer one's, so the driver now latches flow on every tracked entry nested inside the one that just overflowed too.
- The CLI silently installed from the public registry whenever an explicit `SCROLLVARS_REGISTRY` failed to fetch, which could write a public component under the same slug a private registry meant to own. An override now exits 1 naming the URL and the error instead of falling back.
- `scan()`'s removal handling matched a detached node's CURRENT attributes, so stripping `data-sv`/`data-sv-split`/`data-sv-pointer` before removing the node left its tracker, split or pointer lease registered forever. Removal is now driven by the scan's own lease maps (released whenever the element no longer sits inside the scanned scope), independent of what it carries by the time the observer fires.
- `slider()` read whether an element's effective `scroll-snap-type` was already `none` before adding the `.sv-slider` class that itself declares one, so an unclassed element (any element not already carrying the class before the call) always read a plain `none` and had its custom drag/wheel settle permanently disabled. The read now happens after the class lands.
- `mapRange()` floored a reversed range's span at an epsilon, so every reversed range answered 0 instead of the same ratio `sv-range`'s CSS twin computes: `mapRange(.75, 1, 0)` returned `0`, now `.25`; `mapRange(.25, 1, 0)` returned `0`, now `.75`, matching `styles/pin.css`'s `clamp(0, (t - from) / (to - from), 1)` exactly. It now divides by the real span, and steps at `to` only when `from === to` has no ratio to compute at all.
- `toggles()`'s click handler resolved a trigger's `data-sv-target` unguarded, so one click on a malformed selector (a CMS typo) threw, tore down the whole instance, and froze every toggle in that scope in whatever state it was in. `click()` now tolerates an unparsable selector the same way `boot()` already does: it skips that trigger and keeps running.
- A tracker attached under an ancestor that had already switched to document flow (a React `<Track>` retrack, a CMS block mounted later, a manual retrack) never latched itself: its own stage was already released by `[data-sv-flow] .sv-stage`, so it could never measure an overflow of its own, and its tall pin wrapper sat orphaned under a now-static stage. `attach()` now checks for a flowing ancestor and latches immediately.
- fx gallery: `cube-windows` declared `requires.min: '1.9.0'` while its installed source imports `onMotionChange` (1.17.0), so the CLI told a consumer on an older install "ok" and the import failed at runtime; the gallery preview's inline script read `SV` before the engine bundle that defines it had loaded, throwing `SV is not defined` and silently never mounting the effect; and the React component's effect constructed a `ResizeObserver` unguarded, so a browser below the individual-transform floor without `compat()` threw and unmounted the whole root instead of rendering the content whole. `requires.min` is now `1.17.0`, the preview destructures `SV` inside its `load` handler, and both the vanilla and React mount code return early without a `ResizeObserver` (no clip-path, content whole, same contract as every other fallback path). Recategorized from `Sections` to `Pointer` (it renders through the three-format effect pipeline, not the Section preview pipeline).
- fx gallery: `DeckSpread` and `SequencedScrub` wrapped every top-level child individually, the same bug `CoverflowSlider` had (round-15 fix, untwinned): a fragment of two cards collapsed into one fan slot / one scrub window, and `--sv-mid` centred on a card that was not counted right. Fragments are opened recursively now, keyed the same way the coverflow rail already does.
- `refresh()` and the resize path refreshed a pinned entry's stage origin one entry at a time, each one toggling `position: static`, reading `offsetTop` up the chain, then restoring: N pinned entries forced N style recalculations on any document-height change (lazy images, fonts, a feed), not one. The read now happens for every entry after every entry's static override is in place, restoring only once all reads are done, the same read-all-then-write-all shape the pin helper already uses.
- `slider()` treated an authored `tabindex="-1"` (a follower rail deliberately kept out of the tab order) the same as no `tabindex` at all, since both read `-1` through `.tabIndex`, and overwrote it with `0`. The check now reads the attribute.
- The CLI joined an absolute `--dir` under `process.cwd()` (`join('/a', '/b')` is `/a/b`), writing the installed file somewhere under the current directory instead of the given absolute path. It now resolves the path instead of joining it.
- A `<link rel="stylesheet" disabled>` has no parsed sheet either, the same uncertainty signal as one still loading, but nothing ever fires its `load`/`error`: the driver treated it as pending forever and kept `--sv-page`/`--sv-v` publishing for the whole session even with no other consumer. A disabled link is no longer counted as pending.
- `slider()` on an empty rail fired `onSlide(0)` for a slide that did not exist, so when slides arrived later the real first one never got its own `onSlide(0)` (index 0 already read as "active"). An empty rail no longer claims an index.

### Docs

- Corrected two out-of-date descriptions of the failed-scan and page-outputs behavior above: Boot's watchdog stands down on any successful tracker or scan, not on "completed scanner setup" specifically, and a failed scan now settles its own content visible; and the `--sv-page`/`--sv-v` publishing trigger is CSS mentioning them (a `var()` read, a declaration, even a comment), not only CSS reading them.
- "JavaScript never animates styles" (AGENTS.md, llms.txt) had a shipped counterexample: `cube-windows` writes `el.style.clipPath` from its own rAF loop. Corrected to note the exception (cube-windows, the canvas effects).
- AGENTS.md and `styles/scoped.css` pointed at a README hook-examples section, an Accessibility section and a "Scoped clocks" section that moved to `docs/guide.md` whole; corrected to point at the guide. AGENTS.md's own `style={{'--sv-order': i}}` snippet failed to type-check (an excess property against `React.CSSProperties`); corrected to the cast the library itself uses. Three installed gallery components (`HeroCinematic`, `ThreeScene`, `PointerTiltGrid`) carried a redundant ref cast and a comment claiming a ref hook returns `RefObject<T | null>`; every hook has returned `React.RefObject<T>` since ADU-106, and the casts and the false comment are gone.

### Added

- fx gallery: `cube-windows`, a Section recipe where a box turns in 3D on scroll and pointer tilt, its outline clipping the content behind it. The projection math (`scripts/fx-lib/mask3d-core.ts`) is a small, dependency-free, unit-tested module meant to be copied whole into a project.

## 1.18.0 (2026-09-21)

### Changed

- Shortened the README to installation, working examples and documentation links. The complete reference, generated measurements and detailed accessibility guidance now live in `docs/guide.md`.
- Documentation checks compile both the README quick start and guide examples under React 18 and 19. Generated tables and size stamps now update the guide.
- The release gate runs packed-tarball acceptance in Chromium, Firefox and WebKit and publishes the same tested file.
- Packed acceptance now checks real JPEGs, delayed and failed images, live CMS copy and step replacement, narrow layouts, doubled text and both motion controls under React 18 and 19.
- CI and release retain acceptance results and failure diagnostics with source, package, tarball, registry and runtime attribution; the release still publishes the tested tarball.

### Added

- `AttachmentStatus` and `TrackOptions.onStatus` report each attachment lease transition, including successful output, completion, failure and release. `track()` keeps returning its cleanup function.
- `useTrack`, `useScenes`, `<Track>` and `<Scenes>` expose the same status callback. StickySteps uses it alongside fit and computed CSS checks before crossfading or making shots inaccessible.
- A docs-types gate extracts actual README hook and installed Section examples and compiles them through public package exports under React 18 and 19.

### Fixed

- StickySteps contains later controller failures, attempts every cleanup, restores flowing media and React accessibility state, reports once and retries only through explicit remount.
- StickySteps keeps an overflowing replacement candidate measurable until the driver latches flow, and synchronizes accessibility after scene and content changes.
- `<Scenes onScene>` contains a throwing consumer, reports it once and keeps scene state and sibling components working.
- Slider child replacement restores departed slides' owned outputs and active classes and releases their ownership records.
- StatsCountup renders final numbers before activation under both reduced-motion controls in the installed component, gallery CSS and Tailwind panes.
- CLI minimum-version checks compare release and prerelease precedence, including numeric prerelease identifiers and build metadata.
- StickySteps measures its candidate stacked layout before the first fit decision, so intrinsic images that fit can crossfade while oversized content still latches static flow.
- Slider, toggles, pointer and canvas setup release partial acquisitions and restore owned DOM writes before rethrowing the original error.
- Runtime measurement, output, observer and callback failures stop and report only the affected instance, preserving siblings and the scroll driver.
- Cleanup is idempotent during callbacks, dragging and gliding; stale events, observers and queued frames cannot write after release or replacement.
- Slider and pointer cleanup restore owned styles, classes and focusability while preserving subsequent author changes and authored native scrolling.
- Failed toggle operations restore semantic class, state-variable and ARIA values; shared controllers retain their marker and release pending transition holds correctly.
- Canvas setup, resize and frame failures stop the loop and restore authored sizing without changing fallback content, even when a consumer disposer throws. `destroy()` restores the authored sizing too, which resets the bitmap; `pause()` keeps the last frame.
- React auxiliary attachments and autoplay contain failures locally, while failed Modal promotion preserves static open/closed content and close callback failures report once.
- Repeated attachment and release return listeners, observers, timers, frames and toggle ownership to baseline. Existing exports and signatures are preserved.

## 1.17.5 (2026-09-15)

### Fixed

- Slider autoplay suspends during pointer and touch gestures and restarts a full countdown after release or cancel, preserving focus and explicit pause controls.
- The horizontal rail preset and copied formula apply the start override only at the start and align the endpoint to measured stage overflow.
- Installed StickySteps acquires mutation observation and motion subscriptions transactionally, retaining static, reachable shots when setup fails.
- Failed pointer setup releases listeners, its observer and queued work before rethrowing, preserving overlapping scanner owners and explicit retry.
- Demo bundling passes executable arguments separately so checkout paths containing spaces work.
- Scroll initialization commits readiness after required setup succeeds and rolls back partial subscriptions on failure. Throwing optional intersection observers fall back to unculled tracking.
- Failed attachments restore their outputs and authored pin geometry. Scanners retain only acquired leases, unwind partial setup and settle unprocessed content while preserving overlapping owners.
- Measurement, output and callback failures release the affected tracker, report the original error once and allow healthy entries to continue. Stale cleanup cannot release a replacement.
- Boot watchdog expiry is terminal for late tracking and remounts, including when mutation observation fails. Boot releases its scanner if toggle setup fails.
- Installed StickySteps starts static, waits for tracking and fit evaluation before enabling crossfade, and releases layout and accessibility hiding on failure.
- The browser gate now exercises actual Boot and CLI-installed StickySteps with React 18 and 19, injected failures, nonce CSP, motion changes, retry and responsive static reachability. No public API changed.

## 1.17.4 (2026-09-15)

### Fixed

- Overlapping scanners share split text and pointer controllers until their last owner stops.
- Failed ResizeObserver initialization releases prepaint hiding and cannot acknowledge a successful boot. `scan()` now initializes the driver on a route with nothing to track, so `html.sv-on` marks a working driver from the scan on (before, only the first `track()` set it); an `.sv-acts` widget that no `toggles()` controls therefore reads act zero on such a page, as it already did on any page with a tracked element.
- Refresh resolves replacement stage and fit nodes and transfers their resize subscriptions.
- Pin progress and scene navigation include the stage's normal-flow offset before sticking starts.
- Compat rails use measured stage overflow and remain stationary when the rail fits.
- Proximity snap leaves wheel and drag settling to the browser.
- Stopping the last toggle controller releases its enhancement marker and pending settle overrides.
- Default and custom carousel dots expose the current slide and disable redundant activation.
- Pointer tracking clears removed targets without waiting for the container to be destroyed.
- Gallery bundling accepts checkout and output paths containing spaces.
- README accessibility checks describe the actual CI triggers: main pushes and pull-request events.

## 1.17.3 (2026-09-15)

### Fixed

- Share scroll tracking across overlapping scanners until their last owner stops.
- Resolve pin stages within their owning tracker for measurement and scene navigation.
- Preserve authored height and position priorities when releasing pin helpers.
- Honor the page motion switch in Coverflow and GSAP gallery previews.
- Keep static StickySteps shots accessible after failed enhancement or watchdog release.
- Restore the current scroll progress when rebuilding an installed GSAP timeline.
- Wrap unsupported rails, hide decorative curtains and release static legacy deck stages.
- Keep nested scoped-clock readers on their own clocks before the first driver write.

## 1.17.2 (2026-09-14)

### Fixed

- Pin progress with a measured `.sv-stage` under a sticky header (`--sv-pin-offset`)
  counted the offset twice (1.17.1 regression): the stage's height already
  sits below the header, so the pin reached 1 one offset after the stretch
  ended. `scrollToScene()` used the same span. The span is the wrapper's
  height minus the stage's border box now; without a stage it is the
  viewport minus the offset, as before.
- The stage's height was read inside the driver's write phase; it is part
  of the read-phase snapshot now, next to its width.
- Page-output detection skipped `<html>` entirely (to ignore its own inline
  writes), so an inline `var(--sv-page)` reader on the root never turned
  publishing on. The root's style attribute is checked for readers now.
- `toggles()` synced `aria-expanded`/`aria-pressed` only on the triggers
  inside the scope that handled the click; a trigger of the same target
  outside that scope kept its boot value. Every trigger in the document
  reflects the target now.
- `trackPointer()` cleanup missed an element that only ever received a
  `pointerout` (zeroed coordinates and the leave class with no move first).
- Gallery under reduced motion: TimelineScrub's year and line settle at
  their finished value, ThreeScene holds its scrolled pose and stops
  following the pin, the rotating-words preview stops cycling.
- Docs: the scoped-clock forwarding recipe keeps a nested tracker on its own
  clock (`:not(.sv, [data-sv])`); the no-JS Lighthouse claim is scoped to
  the no-JS path; AGENTS says Sections ship as complete React source.

## 1.17.1 (2026-09-14)

### Fixed

- `<ScrollVarsBoot>` on a first route with nothing to track: its pre-paint
  watchdog waited for a flag the driver set only on the first `track()`,
  so at three seconds it dropped `html.sv-on` and kept dropping it for the
  session, and later routes never animated (content stayed visible). The
  scanner marks the driver's arrival now, trackers or not.
- Pin progress spanned the wrapper's height minus the viewport's, so an
  authored stage shorter than the viewport reached `--sv-pin: 1` before
  its sticky stretch ended; the span uses the stage's rendered height,
  in the driver and in `scrollToScene()`.
- `[data-sv-fit]` with a fixed height and overflowing copy passed the fit
  test and clipped; the test reads the content's height too.
- `styles/scoped.css` forwarded a clock through a nested tracker, which
  overrode the nested `.sv`'s own reset until its first inline write; the
  path rules stop at `.sv` and `[data-sv]`.
- `trackPointer()`'s cleanup skipped elements a leave had already dropped,
  leaving their zeroed `--mx`/`--my` and `sv-pointer-leave` behind.
- `sv-counter` kept counting with the pin clock under reduced motion; it
  settles on `--sv-max` like every other scrub preset, under the media
  query and under `data-sv-motion="reduce"`.
- Installed StickySteps watched the OS query only, so under
  `setMotion('reduce')` its shots showed in flow while the inactive ones
  stayed `inert` and `aria-hidden`; it follows the effective preference.
  Installed RotatingWords hides the column from screen readers, says the
  phrase once and stops rotating under reduced motion (the gallery preview
  already did). Both need scrollvars 1.17.0.
- Installed CoverflowSlider passed the nonce to its Slider but not to its
  own `<style>`.
- `scripts/demo-sync.mjs` copied `styles/scoped.css` at import time; the
  copy sits under the run-as-script guard now.
- Docs: the Sections ship as complete React components (not three
  formats); the page-outputs scan cannot see an in-place rule edit or a
  CSSOM insert with no element added; the range floor's contrast claim
  names the tested pair; the Slider's live region wording matches the code.

## 1.17.0 (2026-09-14)

### Added

- A motion preference the page can set: `html[data-sv-motion="reduce"]`
  asks for less motion whatever the OS says, and every
  `prefers-reduced-motion` block in the shipped sheets (core, pin, state,
  tilt, ui, slider, and the compat fallback) has a twin under it, kept in
  step by a test and checked rendered in CI. `setMotion('reduce' | 'auto')`
  sets the attribute, `onMotionChange(fn)` reports the effective preference
  live, and `prefersReducedMotion()` now reads that effective value. The
  driver, the slider's glide, canvas effects and the React `<Slider>`
  autoplay all follow it from one source (`core/motion`), so a change from
  the OS or from the page reaches every animation at once. The OS setting
  alone was the only switch, and it is one many people never find. The
  twins and the module cost about 0.4 KB gzipped across the sheets and the
  driver; the stamped sizes moved with them.
- `styles/slider.css`: a reduced-motion block (it was the only preset sheet
  without one) and `--sv-dot-target`, the hit area of a dot.
- A README section, Accessibility, listing what the library guarantees on
  its own surfaces with the WCAG criteria each guarantee serves, the
  page's side as a checklist (motion guards and the switch, nothing
  essential behind a reveal, a way to stop what moves, readable text for
  animated copy, contrast floors for dimmed text, scroll padding under
  fixed headers, target sizes, reflow and the fit contract, axe then a
  screen reader), and the published guidance the library follows.
- A keyboard-reach gate in the e2e run (`demo/bench/harness/keyboard-gate.mjs`):
  it tabs through every gallery page and the home page, forward and back,
  and requires each focused element to be seen (in the viewport, effective
  opacity at least 0.5, `visibility: visible`, not covered at the center of
  its visible part, still so 250ms later on the way forward). It proves it
  can fail on a
  fixture: a link under a fixed header on the way back, and a link inside a
  box that stays at opacity 0.
- A reflow gate in the e2e run (`demo/bench/harness/reflow-gate.mjs`): at
  320 and 640 CSS pixels of width, every gallery page and the home page
  scroll in one direction only, after boot and after a scroll through, and
  every `[data-sv-fit]` box fits its stage or has released the pin; the
  pinned pages also pass the keyboard gate at 320. It proves it can fail on
  a fixture with a fixed-width band.
- An axe gate in the e2e run (`demo/bench/harness/axe-gate.mjs`): every
  gallery page and the home page pass axe-core's WCAG 2.x A and AA rules
  with zero violations, audited after boot and again after a walk down the
  page a viewport at a time, at the bottom, the middle and the top (it
  waits for every finite animation and transition to end first, since
  text mid-fade reads as low contrast). It proves it
  can fail on a fixture with an image without alt and a button without a
  name.

- `scrollvars/styles/scoped.css`, an opt-in sheet that registers `--sv-t`
  and `--sv-view` non-inheriting so a write re-resolves one element instead
  of its whole subtree. The rule it imposes: a clock reaches only the
  elements that declare `inherit` for it, and every element between the
  tracked ancestor and a reader must declare it too; the shipped presets that
  read a clock from a descendant (`sv-drift`, `sv-range`) are forwarded inside
  the sheet, and a test derives that list from the stylesheets so a new
  preset cannot ship without its forward. A registered property always has
  a value, so a `var(--sv-t, 1)` fallback is never taken again under the
  sheet: declare that default on the tracked element instead. Measured
  behind a rendered-output
  gate: 63 percent less style recalculation and 28 percent less task time on
  the benchmark's deep profile, 13 percent more recalculation and 4 percent
  more task time on its flat profile; on the site's own home page 10 percent
  less task time and 23 percent less recalculation.
  Browsers without `@property` keep inheriting, so it never breaks a page
  below the floor. Not part of `styles.css` on purpose.

### Fixed

- Demo: the site honors its own motion switch. The gallery pages and the
  home page carry a "Motion" control in the header that sets
  `data-sv-motion="reduce"` through `setMotion()` and remembers it
  (`localStorage`), applied by an inline script in `<head>` before anything
  paints; every reduced-motion rule in the gallery effects (panes and
  installed components) and in the home's own CSS has its twin under the
  attribute, kept in step by the same test that covers the library's sheets.
  Tailwind panes keep `motion-reduce:`; the consumer guide shows the
  one-line variant for the attribute.
- Demo: the gallery's code tabs overflowed a 320px viewport by 10px, so
  twelve pages scrolled sideways (WCAG 1.4.10); the row wraps now. The
  reflow gate found it.
- Demo: what axe found. Every code block is keyboard focusable
  (`tabindex="0"`, WCAG 2.1.1: a scrollable region needs keyboard access);
  the sticky-steps section keeps an inactive step's text at 4.5:1 (its
  opacity floor is .65 with the labels at .8, it was .3 with .7 and .75,
  which read at 1.8:1); the editorial manifesto keeps unread copy at 3:1
  (floor .55, it was .28); the cinematic hero's marquee strip has its own
  ground, its text read at 2.6:1 where an orb passed behind it. The gallery
  panes and the installed components carry the same values.
- Demo: the gallery pages' fixed header covered a focused element the
  browser scrolled to the top edge on the way back (Shift+Tab into the code
  block's controls), WCAG 2.4.11. The pages set `scroll-padding-top` under
  the header now; the keyboard gate found it and keeps it.

### Changed

- `sv-range-rise` fades a slice in from `--sv-range-floor` (default .55)
  instead of from 0, the way `sv-reading` already floors at
  `--sv-reading-floor`: a child waiting for its slice kept its text at
  2:1 or less on the sequenced-scrub and timeline pages (axe, walking the
  page a viewport at a time). The default keeps the sheet's text color at
  4.5:1 on a dark ground; a muted or accent color needs a higher floor (the
  sequenced-scrub demo uses .8 for its accent line). Set
  `--sv-range-floor: 0` on the container for the old look, at the cost of
  that audit. A unit test pins the floor's arithmetic.
- Slider dots are 24 by 24 CSS pixel targets (WCAG 2.5.8 Target Size,
  Minimum): the button is the target and the visual dot is drawn inside it
  as `::before`, so `--sv-dot-size` still sizes what you see and
  `--sv-dot-target` what you hit. An override written directly on `.sv-dot`
  (rather than through the knobs) now styles the button box; move it to
  `.sv-dot::before`. The dots row is 24px tall instead of 8px.
- `<Slider autoplay>` starts paused under reduced motion and pauses the
  moment the preference flips; the visible control still resumes it. It
  kept rotating before (jumping instead of gliding), and a glide in flight
  when the preference changed finished animating: it settles now.

- `--sv-page` and `--sv-v` are published only when something in the document
  can read them. Both live on `<html>` and both inherit, so every write asked
  the browser to recalculate style for the entire document, on every scroll
  frame, whether or not a single rule referenced them. On the 900-box
  benchmark that cost 3249ms of style recalculation over a 12-second scroll
  against 269ms without, and 4128ms of total task time against 1696ms; the
  same page with the identical writes registered `inherits: false` cost
  267.5ms, so the price was the inheritance rather than the write. The driver
  now scans the document's own stylesheets and inline styles once, on the
  first frame that could publish, and stays silent when neither name appears.
  A stylesheet added later turns publishing back on; the watch behind that
  rescans once per frame that adds an element and skips every stylesheet it
  already read in full (by rule count, so a rule inserted into an existing
  sheet as a component mounts is still seen), which is what keeps a page
  that mounts elements while it scrolls from serializing every rule on
  every one of those frames.
  **This can change behavior for a page that reads the variables only from
  JavaScript**, which no CSS scan can see: call `setPageOutputs(true)` there.
  Anything unreadable counts as a reader, so a cross-origin stylesheet without
  CORS headers keeps publishing exactly as before. Calling `setPageOutputs()`
  at all now takes the decision away from detection permanently, in both
  directions.

### Performance

- The published main-900 benchmark, same page, same methodology: total task
  time 679ms against gsap-batched's 626ms and framer-motion's 1078ms, with
  59ms of script against 117ms and 490ms, and 1.0MB of heap against 6.8MB
  and 10.9MB. Before this change the same page cost 4144.5ms, 2.6x GSAP
  rather than 1.08x. On the deep profiles the default still loses to GSAP on
  style recalculation alone, and `styles/scoped.css` is the answer there:
  ahead of GSAP at five descendants per box, the gap at fifty narrowed from
  72 percent to 24.


- Demo: keep the homepage's custom scenes readable before the driver boots. Entrance text stays visible; pinned rails, decks, product-tour panels and map stations return to normal flow; galleries stop clipping static cards. Native carousels remain keyboard-scrollable without JavaScript, including the page-driven rail. Canvas-only effects and inactive controls are omitted from the static preview.
- Demo: the homepage no longer calls `setPageOutputs(false)` itself. The
  default now detects that nothing on the page reads `--sv-page` or `--sv-v`
  and stays silent, which is what the earlier explicit opt-out had been
  buying (measured at the time at about 62 percent less main-thread task
  time over the same 12-second scroll, 5039.5 to 1911.5ms).
- Demo benchmark table: the same dated snapshot as the README and the
  benchmark page, with three ScrollVars rows (the default, the
  `styles/scoped.css` opt-in, and the document variables published, which is
  the cost of that feature rather than a competitor comparison) next to the
  batched GSAP row.
- Demo benchmark page: the low-end profile (4x CPU throttle) re-measured with
  the new default, six rows instead of a table that still described the old
  one; and a new section for an app-shaped page, generated by the harness
  (`harness/app-shaped.mjs`: a page that mounts and unmounts rows while it
  scrolls and swaps its grid and feed at six seconds), showing what the
  library adds to a page that lives, with the same line stamped into the
  README's benchmark block. `harness/mutation-cost.mjs` is the diagnostic
  behind the consumer-watch fix above; both ship with their results.

## 1.16.1 (2026-09-09)

- Driver: repeated `setPageOutputs(true)` calls no longer schedule frames when outputs are already enabled. Calling it from `onTravel`/`onPin` previously sustained a frame loop on a stationary page. A Chromium/Firefox/WebKit probe now records zero callbacks and element-geometry reads during the idle window; real re-enabling and subsequent scroll input still update normally. This removes unnecessary idle work in that callback pattern, not the general benchmark's inherited-style cost.
- Driver: enabling page outputs inside a tracking callback waits for a frame with measured document geometry. Previously it could briefly publish `--sv-page: 1` halfway through the page. Disabling remains immediate; the fix adds no layout reads during the write phase. Covered for live/travel/pin/scene callbacks and checked in Chromium, Firefox and WebKit. This is a correctness fix, not a measured CPU speedup.

## 1.16.0 (2026-09-09)

- Add `cssVars: false` to `slider()`/`useSlider()` and the `<Slider>` kit to skip unused `--sd`, `--sv-progress` and `--sv-slide` writes in plain carousels. Default output behavior, callbacks, numeric state, active classes and snap are preserved; existing inline values are left intact. Unit, React 18/19 and Chromium/Firefox/WebKit checks cover both modes.
- **36–54% less main-thread CPU time in the measured plain-slider workloads**, when opting out of unused CSS outputs. Continuous `seek()` benchmarks fall from 1145 to 615 ms (15 cards, **46.3%**) and 2784.5 to 1288 ms (120 cards, **53.7%**). Animated `goTo()` benchmarks fall from 508.5 to 323.5 ms (**36.4%**) and 1319 to 603.5 ms (**54.2%**), respectively. Each workload uses four runs per mode and size with rotated execution order; both modes already deliver 60fps. These are CPU savings, not FPS gains or a general speedup over GSAP/Framer. Sliders whose CSS consumes these variables must keep outputs enabled. [Seek measurements](https://scrollvars.dev/bench/results/slider-api.json), [glide measurements](https://scrollvars.dev/bench/results/slider-glide.json), [methodology and limits](https://github.com/aduptive/scrollvars/blob/main/demo/bench/harness/README.md#unused-slider-outputs-plain-carousel).
- Refresh the [general benchmark](https://scrollvars.dev/bench/) for 1.16.0 and lead with total CPU and frame delivery, retaining the style-cost breakdown and both page-output modes. This comparison does not establish a general speedup since 1.15.0; document-wide outputs remain the main measured weakness.

## 1.15.2 (2026-09-09)

- Slider: retarget an active glide when the existing resize/child-list observers report changed geometry; stop cleanly if all slides disappear. This prevents an old destination from driving repeated out-of-range writes after a responsive resize or content removal, without adding per-frame geometry reads.

- Slider: clamp glide destinations to the real scroll range so partially visible edge slides do not cause animation frames that keep writing unreachable positions. Deliver a final stopped-state measurement even if the browser rounds away the last scroll event. Covered at both edges in LTR, RTL and vertical rails; fitting rails schedule no glide.

- Canvas: pause the automatic frame loop when either layout dimension becomes zero, even when IntersectionObserver still reports an intersection. Restoring the previous size resumes without recreating the simulation; manual pause and `autoPause: false` remain respected. Covered by unit and Chromium/Firefox/WebKit regression checks.

## 1.15.1 (2026-09-08)

- Pointer: stop rewriting an unchanged leave class on every pointer event; write each coordinate only when its inline value changes. Hidden targets settle to zero instead of `NaN`, and queued moves skip detached targets. External style replacements are still repaired.
- Slider: read the scroll range once per progress snapshot and avoid copying all children just to count them.
- Driver: skip the velocity-reset timer when already at zero and keep the reset's zero serialization consistent, avoiding redundant document-wide style changes at rest.
- Regression checks cover the actual pointer class mutations and hidden targets in Chromium, Firefox and WebKit, plus coordinate writes, detached targets, slider snapshots and idle velocity writes.

## 1.15.0 (2026-09-08)

- Add `setPageOutputs(false)` and `<ScrollVarsBoot pageOutputs={false} />` to stop unused document-wide progress/velocity writes. Local tracking keeps working; the existing default remains enabled for compatibility. Gallery pages opt out.
- Fix nested sliders stealing drag/wheel gestures, settling from a stale slide index on release, and pin offsets becoming stale after observed container resizing.
- Move the tilt glare with a translated fixed gradient instead of changing its painted gradient center.
- Benchmark the same scroll path through a shared runner, separate startup from scroll CPU, preserve long frames, record individual runs and source hashes, and compare page outputs on/off plus six gallery sections. Synthetic CPU throttling is calibrated with fixed work and is not a physical-device result.
- Verify deployment aliases against a unique deployment ID. Add Chromium/Firefox/WebKit regressions for the interaction fixes and correct performance claims and setup guidance.

## 1.14.2 (2026-09-07)

- Fix TimelineScrub and StickySteps disabling their pin in bordered stages. Their inner layout now fills the stage content box with `min-height: 100%`, instead of exceeding it by the border width. Added scroll-behavior checks against the actual gallery pages; genuinely oversized content still returns to flow.

## 1.14.1 (2026-09-07)

- Fix horizontal rails clipping their last cards inside narrow stages, including the fx gallery. The driver measures `--sv-stage-width` in its read phase and observes stage resizing; the preset uses this width by default while preserving `--sv-rail-start` overrides.

## 1.14.0 (2026-09-07)

### Review fixes and gallery sections
- Pinned sections can opt into `.sv-stage > [data-sv-fit]`: tall content returns to flow, releases the pin height and reports `onFlow`. TimelineScrub and StickySteps use it, including accessible media in flow.
- Pin offsets use the stage's computed CSS top; calc(), env(), percentages and viewport units stay aligned with CSS.
- Slider: isolate nested per-view rules, nonce the responsive stylesheet, expose hover/focus pauses to live regions, retain explicit focus pauses and stop callbacks after destruction. Remove flatMap from the React child flattener for legacy consumers.
- Marquee gets a keyboard pause control; toggles includes its scope root and supports pressed-state buttons. Compat ResizeObserver supports disconnect/reobserve. Canvas never resizes disposed setup resources.
- scan owns declarative pointer containers and CSS timing/ease attributes.
- Gallery Sections expose their exact installable source and visual previews; add CaseStudyRail and EditorialManifesto. Documentation corrects observer gating, replay reads, stagger limits and named-import size measurement.

### Tooling (ADU-196)
- Three `.replace()` calls in `scripts/docs-stamp.mjs` (README's total size
  stamp, README's slider module size stamp) and `scripts/demo-sync.mjs`
  (`demo/index.html`'s version line) ran with no guard at all: worse than
  the "missing anchor" case ADU-195 already covered, since a bare
  `.replace()` that matches zero times reports success and silently writes
  the target file back unchanged, the old number still on the page. A
  sweep of every other `.replace()`/`.match()` in `scripts/` for the same
  defect found two more in `docs-stamp.mjs` (AGENTS.md's two styles-import
  lines) and two in `scripts/bench-tables.mjs` (the bench page's runner
  config bundle size, the README/AGENTS "less bundle" ratio sentence), all
  fixed the same way. `docs-stamp.mjs` and `demo-sync.mjs` gain a
  `spliceOne()` (throws on zero or more than one match, ADU-195's
  `floorRow()` shape generalized to a plain regex); `bench-tables.mjs`
  gains `spliceAll()`, which throws only on zero matches since a repeat is
  the intended shape there (the same bundle size legitimately repeats
  across three runner-config entries, the same ratio across README and
  AGENTS). `bench-tables.mjs` also gained the `isMain` guard the other two
  scripts already had, so `spliceAll()` can be imported by a test without
  running the whole script's file I/O.
- Fix pass on the above: the audit that closed this ticket was itself
  incomplete on two counts. `spliceAll()`'s "at least one" check accepted
  PARTIAL coverage: on the bench page's three-entry runner config, breaking
  one of the three matches (a moved anchor, a stray quote) still exited 0,
  with the other two silently rewritten and the third silently left stale.
  It now takes the caller's own expected count (3 for the runner config)
  and throws on anything else. The README/AGENTS "less bundle" ratio
  sentence never shared that repeat shape, it runs once per file and
  expects exactly one match each, so it moves to `spliceOne()` instead:
  an accidental second mention in either file now throws rather than
  being double-patched. And "every other regex-driven write in `scripts/`
  was audited and is already guarded" was false: four more calls sat
  beside the ones that were fixed, each guarded less than that sentence
  claimed. Unlike the five above, which had no guard at all, these did
  check for a missing anchor, then wrote through a bare non-global
  `.replace()`: an ambiguous anchor silently patched the first match and
  left the rest stale, the narrower case ADU-195 exists to close. They are
  README's per-style styles-import loop, README's intro sizes sentence,
  and AGENTS's "Fully animated" browser-floor headline in
  `docs-stamp.mjs`, and the bench page's `h1` headline and body ratio
  claim in `bench-tables.mjs`. All four now go through `spliceOne()`.
  `scripts/fx-render.mjs`'s `fxsticky` class splice stays a no-op for
  Section previews that are not pin-based (hero-cinematic, stats-countup
  render no `.sv-stage` by design), but is no longer unconditional: a
  pin-based Section (`requires.styles` includes `pin`) that loses its
  `.sv-stage` now throws instead of silently shipping without it. No
  output changes: `npm run demo:sync` produces a byte-identical tree.

### Tooling (ADU-195)
- The anchor splices in `scripts/docs-stamp.mjs` and `scripts/demo-sync.mjs`
  (`between()`, `stamp()`, `floorRow()`, and every regex-based row splice in
  demo-sync's browser floor and CPU tables) now throw when their anchor is
  AMBIGUOUS, the same way they already throw when it is missing. `between()`
  counts the literal `before` anchor rather than the compound regex: a
  non-greedy match starting at a repeated `before` silently absorbs the
  other occurrences, so counting the compound match reports one and misses
  it, which is exactly the failure that let ADU-194 nearly ship a splice
  that matched the wrong occurrence, caught only because the NEXT splice
  threw on the anchor the first one had already swallowed. Structural
  splices (`stamp()`, `floorRow()`, demo-sync's marker blocks and table
  rows) count the whole constructed regex instead, since a bare label can
  legitimately repeat across two tables with different cell shapes
  (demo-sync's CPU table and the Lighthouse table both have a "ScrollVars"
  row) without being ambiguous for that splice. Every anchor in the tree
  today was audited against its real target file and found unique; no
  splice needed widening. `between()` and `stamp()` are now exported from
  their scripts (guarded behind an `isMain` check, `scripts/fx-build.mjs`'s
  existing pattern) so tests can call them without triggering the scripts'
  file I/O. No output changes: `npm run demo:sync` produces a byte-identical
  tree.

### Docs (ADU-194)
- The bench page's own headline contradicted the measured table two screens
  below it: "15× less JavaScript" against a stamped bundle ratio of ~7×
  (46.3 / 6.7), and "three engines" as the whole page's frame, above a
  table that now has four rows. Fixed the ratio, which is unambiguously
  wrong regardless of framing; left "three engines" as written both times
  it appears, since each instance describes the interactive live-run
  section immediately below it, which genuinely only ever exercises three
  builds (ScrollVars, idiomatic gsap, framer-motion; the batched gsap
  variant is the harness-only fourth row, never added to the interactive
  tool, which would be a behavior change this ticket does not ask for).
- The home page repeated the same numbers by hand in five more places, all
  drifted: the og/twitter meta descriptions ("1.4 KB driver" against the
  footer's own stamped "2.9 KB"; a bare, uncaveated "Lighthouse 100" claim
  nothing in the repo sources, handled the same way ADU-193 handled the
  identical claim in the launch draft: it goes rather than getting carried
  forward stale), the slider case's headline and body prose ("30× lighter
  than Swiper" against 42 / 2.3 ≈ 18×), a demo slide reading "3 KB, not
  150", "the receipts" section's own weight and CPU tables (a "6.1 KB"
  ScrollVars-everything row one screen above the footer's correct "6.7 KB"
  full core; a driver-alone row still reading a size from before the
  driver grew; a CPU table with numbers from an earlier benchmark run
  entirely), and a footer honesty note's "< 6 KB" bound, false now that
  the full core is 6.7 KB. Every number above is now derived, not typed:
  see the Tooling entry below.

### Tooling (ADU-194)
- `scripts/docs-data.mjs`'s `measureSizes()` now also exposes minified
  (pre-gzip) sizes for the driver, the core entry and the slider module
  (`driverMin`, `everythingMin`, `sliderMin`), the two figures the home
  page states side by side with a competitor's own minified size; a new
  `benchMainEngines()` reads the committed harness output
  (`demo/bench/results/latest.json`) the same way `scripts/bench-tables.mjs`
  already does, and `cpuTotalMs()` is the site's own definition of "CPU
  total" (script + style recalc + layout, the same formula the ADU-193
  article fix already established). gsap + ScrollTrigger's bundle size
  (46.3 KB, from its public CDN build, ours to cite but not to measure)
  moves to a single exported `GSAP_KB`; `scripts/bench-tables.mjs` reads it
  instead of carrying its own copy.
- `scripts/bench-tables.mjs` now also stamps the bench page's own headline
  ratio and bundle-ratio sentence from the same `ratio` it already computes
  for README and AGENTS, so a future driver-size change moves all three
  the same way instead of two of three.
- `scripts/demo-sync.mjs` gained a `between()` anchor splice (docs-stamp.mjs's
  idiom, copied rather than imported cross-module) and now stamps every
  number named in the Docs entry above on `demo/index.html`: the meta
  descriptions, the slider case's headline and body prose, the hero
  carousel's driver-size card, the slider demo slide, and "the receipts"
  section's three ScrollVars rows in the weight table plus its whole CPU
  table. No new path outside what `.github/workflows/ci.yml` and
  `release.yml` already diff (`demo/index.html` was already on both
  lists). No behavior change.

### Docs (ADU-193)
- `article/launch-kit.md` and `article/why-i-built-scrollvars.md` (the
  launch drafts, not shipped by npm or served by the site, so no CI gate
  covers them) carried headline numbers from an earlier point in the
  engine's growth: the bundle used in the Show HN title, the tweet and the
  email pitch was 3.9 KB, now 6.7 KB (README's own bench-table figure),
  and the bundle multiplier was ~12x, now the ~7x the bench tables stamp.
  `why-i-built-scrollvars.md`'s driver size, carousel size, comparison
  table and CPU/heap paragraph were reconciled the same way against
  `scripts/docs-data.mjs`'s measured sizes and the committed bench tables;
  its pattern count went from 26 to 28, matching AGENTS.md and
  `demo/index.html`. The Lighthouse mobile scores in that same paragraph
  need a fresh run against the deployed site (a live measurement, not a
  build output) before the piece publishes: left as a marked placeholder
  rather than carried forward stale. No code change.

### Docs (ADU-181)
- The "fully animated" browser floor had SIX hand-typed copies and three of
  them had drifted: `docs/integration.md`, `demo/index.html` and the launch
  draft in `article/`, which is not published from this repo, all said
  Firefox 74+ (Mar 2020), which is not a bound anything in the code sets.
  README, AGENTS.md and `scripts/docs-build.mjs`'s /docs/ template already
  agreed on the true number, Firefox 78+ (Jun 2020): Firefox shipped
  individual transform properties (`translate:`/`rotate:`/`scale:` as
  their own CSS properties, the other half of the floor) in 72, Jan 2020,
  but `:is()`/`:where()` only in 78; without `:is()`/`:where()` a parser
  drops the whole `.sv-on :is(.sv, [data-sv]) ...` selector list that
  core.css, pin.css and state.css write for every enhanced-path rule, so
  78 is Firefox's real binding constraint, not 72 or 74. Chrome (104, Aug
  2022) and Safari (14.1, Apr 2021) are bound by individual transforms
  either way, both already past their own `:is()`/`:where()` floors. Fixed
  all three to Firefox 78+. The five copies that ship now render from one
  source; the draft sits outside the generated pipeline and was corrected by
  hand.

### Tooling (ADU-181)
- The browser floor's five shipped copies now render from one
  `BROWSER_FLOOR` in `scripts/docs-data.mjs`, next to `COMPAT_PRESETS`
  (ADU-176's pattern):
  `scripts/docs-stamp.mjs` splices README's and AGENTS' cells (the
  anchor-based splice, since a floor cell sits inside a Markdown table row
  and a prose sentence, neither of which can carry a `<!-- name:start -->`
  marker) and now also owns `docs/integration.md`, previously untouched by
  any generator; `scripts/demo-sync.mjs` splices `demo/index.html`'s table
  row and footer sentence, the two spots that had drifted; and
  `scripts/docs-build.mjs` interpolates the /docs/ template directly from
  the same data, no regex needed there since that file is rendered fresh
  every run. `docs/integration.md` and `demo/index.html` were not on
  either gate's file list, so both `.github/workflows/ci.yml` and
  `.github/workflows/release.yml` now diff them too, alongside the
  surfaces already covered. No behavior change.

### Docs (round 8, ADU-192)
Docs read against the code merged by the four round-8 code tickets.
- README's and AGENTS' `sv-range` paragraphs no longer say a `var()`
  fallback makes older engines settle at the end state: `var()`'s fallback
  fires only for a guaranteed-invalid property, and `--sv-r` is set. It is
  the registered property's own `@property --sv-r { initial-value: 1 }`
  that resolves an engine that cannot compute the calc() division to that
  initial value instead of turning invalid. The `var(--sv-r, 1)` a
  consumer writes never fires on a range child at all, with `@property` or
  without it: `--sv-r` is set either way, so an engine that cannot compute
  the division turns the declaration consuming it invalid at
  computed-value time instead of substituting the fallback. The paragraphs
  say that rather than calling the fallback a backstop, which reads as
  protection it does not give. Same fix in the gallery's `sv-range`
  Tailwind pane comment (`scripts/fx-data.mjs`) and in `styles/pin.css`'s
  own `sv-range` comment, which derived its "always consume it with a
  fallback" advice from the same wrong mechanism: the advice and the
  outcome stay, the causal claim goes.
- README's and AGENTS' `--sv-pin-offset` unit lists now say what `em`
  resolves against: the pinned stage's font-size, not the tracked
  wrapper's, per ADU-191. `rem` gained its own parenthetical (root
  font-size) for the same reason: a unit list that only names tokens
  cannot tell a reader which element a relative unit is measured from.
  AGENTS' copy of the list also names the `svh`, `lvh` and `dvh` group
  README carries, which it had been missing.
- README's and AGENTS' entrance-replay paragraphs now name the two shapes
  ADU-191 left unreplayable, since the promise is true enough now that a
  reader relying on it hits them: entrance CSS of your own that hard-codes
  its duration instead of reading `--sv-duration`/`--sv-stagger`, and
  `--sv-duration` or `--sv-stagger` declared on a descendant instead of
  inherited, which is what `<Item duration>` and `<Split duration>` emit.
  Only those two knobs: a bare `<Split>` emits a count and per-word orders
  and no timing knob at all, and a descendant `--sv-order` or
  `--sv-distance` replays perfectly. Both surviving shapes stay flat, with
  no visible change at all, when the re-track runs in a plain task (a
  React effect, a click handler), which is the common case; the dip and
  reverse needs a re-track from inside a rAF callback, since in a plain
  task the transition's start time is the next frame's and the driver
  re-asserts the flag in that frame before the clock advances.
- AGENTS' zero-wrapper paragraph no longer says the pre-paint script
  "removes it again if the driver never boots": ADU-188 made the release
  final, so it also removes `sv-on` when the driver boots LATE, after the
  3 second watchdog, reverting the class through a MutationObserver
  installed only once the watchdog fires, instead of re-hiding content the
  visitor is already reading.
- `src/core/driver.ts`'s `Entry.pinOffset` field comment said the offset
  is read once from the tracked element's computed `--sv-pin-offset`. That
  held only for the units resolving identically on either element: `4em`
  reads 40 on a wrapper where the stage gives 80, and custom properties
  inherit downward only, so an offset declared on the stage itself is
  invisible to a read on the wrapper. `readPinOffset` reads it from
  `.sv-stage` since ADU-191; the comment now says so, the wrapper only
  when there is no stage.

### Driver (blind review round 8, ADU-191)
- `--sv-pin-offset` in `em` now resolves against `.sv-stage`, the element
  styles/pin.css applies it to, instead of the tracked wrapper: `top:` and
  `height:` consume the variable on the stage, so that is the font size the
  CSS resolves it against and the element an author can redeclare it on.
  Measured in Chrome, a wrapper at `font-size: 10px` around a stage at
  `20px` renders `--sv-pin-offset: 4em` as `top: 80px`, while the pin math
  used 40 and the pinned stretch started 40px off. Every other unit is
  unchanged, and the whole read moves to the stage, so a value declared on
  the stage itself is the one the math sees too. No stage in the markup
  (`onPin` alone, custom skeletons): the wrapper, as before.
- Re-tracking replays the entrance again, on an element that never left the
  viewport. README, AGENTS and llms.txt all name re-tracking as the way to
  replay it, and it did nothing: the presets are CSS transitions off the
  inherited `--sv-live`, and a frame's rAF callbacks run BEFORE that frame's
  style update, so between the release (which settles the element visible
  with an inline `--sv-live: 1`) and the first frame writing that flag back,
  the computed value went 1 to 1 and no transition was ever generated.
  Measured in Chrome: opacity flat at 1 for six frames and 400 ms. A forced
  style update on its own is not the fix either, it starts the fade OUT and
  the next frame reverses it from wherever it got to, measured 0.938, a dip
  rather than an entrance. `track()` now zeroes `--sv-duration` and
  `--sv-stagger` on the element, forces the update, and hands both knobs
  back with the priority they were authored at, `!important` included: the
  reset lands in one step with no transition to reverse, and the entrance
  runs from a real 0 at the authored duration and stagger. Only for an
  element the driver had settled (released, or a settled `once`), so a first
  track pays nothing at boot. Two shapes are not covered, and behave as they
  did before: entrance CSS of your own that hard-codes its duration instead
  of reading the knobs, and a knob declared on a DESCENDANT of the tracked
  element rather than inherited from it (`<Item duration>`, `Split` and the
  staggered-reveal pane all emit one that way), since a descendant's own
  declaration beats an inherited value at any priority. Both keep the old
  behaviour exactly: a 0.938 dip and reverse when the untrack and the track
  sit a frame apart, no visible change at all when they sit in the same
  tick. One narrow cost the other way: the knobs are inherited, so for the
  single flush the zeroes reach every other consumer in the subtree, and an
  unrelated transition created in that same tick (an accordion opened right
  there and then) is created with duration 0 and snaps. Proved in Chrome by
  e2e invariants, red on the old engine: the replay itself (lowest opacity 1,
  no replay at all) and the handback half of the round trip, that the
  authored priority survives it (computed `--sv-duration` back at the
  authored 400ms against a sheet rule's 3000ms `!important`, not taken over).
  The zeroing half of that same round trip, that the zero itself needs
  `!important` too or the sheet rule outranks it mid-flush and the reset
  never lands, is proved in Chrome as well (the knob's own rise child dips
  below 0.1 under that exact conflict, not just the final value round
  tripping) and, structurally, by a unit test on the knob round trip that
  reads the priority a stub was handed mid-flush rather than whether it won
  a real cascade.
  Left alone on purpose: the forced read cannot throw on a live element in
  any browser, the re-scan's N style recalcs against the base's one are fine
  at realistic N, and the stage lookup takes the first `.sv-stage` at any
  depth, which no shipped page can reach.
- Size, measured against the base, because these are published numbers: the
  fixes above take `track` (min+gzip) from 2.7 to 2.9 KB, `track` + `scan`
  from 3.7 to 3.8 KB, the core entry from 6.6 to 6.7 KB, `scrollvars/react`
  from 12.1 to 12.2 KB and the headline typical page from ~5.2 to ~5.3 KB.
  The driver bundle lands at 2946 bytes, 74 short of the 3.0 KB rounding
  boundary at 3021. No public surface change: no new export, no new class,
  no new variable.

### React (blind review round 8, ADU-188)
No public surface change: no new export, prop, class or `--sv-*` variable.
- `<Slider>` counted its children with `React.Children.count`, which counts
  what the caller wrote and not what React renders. A conditional slide
  (`{show && <Slide/>}`) counts as one child and renders none, so
  `[<Slide/>, false, null, <Slide/>]` drew four dots for two slides, two of
  them calling `goTo(2)` and `goTo(3)` on a two-slide engine, with labels
  reading "1 of 4" and "4 of 4". A fragment counts as one child and renders
  two, so one dot covered two slides and `cloneElement` put `role`,
  `aria-roledescription` and `aria-label` on the Fragment, where React drops
  them: neither slide carried any of the annotation the component promises.
  Both shapes are ordinary React. One normalized list now feeds rendering,
  counting and annotation alike, so the dots, the labels and the engine
  agree on the same slides. `Children.toArray` does not open fragments on
  its own (measured under React 19.2.8, a fragment comes back as one
  element), so the list recurses into them and keys each level under its
  parent, joined with a `:`. That separator is the one React itself escapes
  (`:` becomes `=2`, `=` becomes `=0`) while `.` and `$` pass through a key
  untouched, so a fragment's children cannot collide with their uncles:
  joined on nothing, `<Fragment key="a"><b/></Fragment>` and a sibling keyed
  `a.$b` both flattened to `.$a.$b`, and a reorder then matched the two by
  position and swapped their state.
  A child that is not an element keeps its place in the rail: it carries no
  annotation and the engine cannot count it, but it renders. That matters
  most for a portal, which `Children.map` hands back untouched and
  `toArray().filter(isValidElement)` deletes from the document with no
  warning anywhere.
- The responsive `perView` stylesheet scoped its rules with a descendant
  selector, `[data-sv-uid="..."] .sv-slider`. The rail of a Slider nested
  inside a slide is a descendant of the outer shell too, so the outer map
  declared `--sv-per-view` directly on the inner rail, which reads its own
  value only by inheritance from its own shell: the outer number won every
  nesting. It is a child combinator now, `[data-sv-uid="..."] > .sv-slider`.
- The pre-paint script releases the page after 3s so a bundle that never
  arrives still fails visible. A driver that booted after that deadline
  found the page released, added `sv-on` back, and every offscreen entrance
  went to `opacity: 0`: content appeared and then disappeared. The release
  is final now, held by a MutationObserver that is installed only once the
  watchdog has fired, so the normal path pays nothing for it.

### Gallery (blind review round 8, ADU-188)
- Three installed components carried the same pair, `React.Children.count`
  for the geometry and `React.Children.map` for the render, and
  `Children.map` calls its callback for `false` and `null` too. Given
  `[<Card/>, false, null, <Card/>]`, `CoverflowSlider` wrapped a conditional
  card in a real `<Slide>`, so the rail carried an empty slide and the Slider
  drew a dot wired past the end of the engine; `DeckSpread` emitted four
  cells, two of them empty, and set `--sv-mid` to 1.5 where two cards need
  0.5, fanning the deck around a card that is not there; `SequencedScrub`
  emitted four slices and gave the second real card the scrub window
  0.75 to 1 instead of 0.5 to 1. All three now count the elements they
  render. A child that is not an element passes through unwrapped, as in the
  kit: it owns no slot in the fan, no slice of the pin and no dot, but it
  still renders.

### Slider (blind review round 8, ADU-190)
- `measure()` declared a read phase then a write phase in its own comment,
  then broke it: `progress()` (which reads `scrollLeft` and `scrollWidth`
  through `pos()`/`range()`) ran again after the `--sd` writes, for the
  `--sv-progress` write, and again after the class writes, inside `state()`,
  for `onScroll`. The twin of the ADU-168 driver fix, same shape, in the
  slider: `progress()` is now snapshotted once, still inside the read
  phase, before the write loop starts, and both the `--sv-progress` write
  and the `onScroll` state hand in that snapshot instead of reading fresh.
  `state()` itself is unchanged for any caller outside a measure pass; it
  still reads `progress()` fresh when called with no argument. No API
  change, and not the round-6 value-consistency claim, which stays
  rejected: this is about the interleaving, not about the value read.
- Size, measured: `slider` (min+gzip) goes from 2.2 to 2.3 KB.

### Tooling (round 7 follow-up, ADU-176)
- The compat fallback preset list had THREE hand-typed copies, not two:
  `src/compat/index.ts`'s header comment (which tsc emits verbatim into
  dist and npm ships), README's Extended floor paragraph, and the "Older
  targets" paragraph in `scripts/docs-build.mjs` that renders /docs/.
  Nothing held them together, and the list already escaped once that way
  (ADU-159 shipped a false sentence to npm). All three now render from a
  single `COMPAT_PRESETS` in `scripts/docs-data.mjs`: `docs-stamp.mjs`
  writes the first two, `docs-build.mjs` interpolates the third, and both
  the CI and the release gate diff `src/compat/index.ts` alongside the
  other generated files. Editing the prose by hand is now the thing that
  fails, and the second and third edits stop being needed at all.
- Two prose claims that live in both a shipped source comment and
  README.md now have a test holding them to the code they describe,
  instead of a CLAUDE.md rule nobody enforced: the driver's pin-helper
  release condition (`src/core/driver.ts`'s `track()` comment vs README's
  pinning paragraph, compared as full normalised text), and
  `--sv-pin-offset`'s unit resolution, where both prose copies are now
  checked against `readPinOffset`'s own `switch` rather than against a
  list of unit names. A unit list keeps every token when a unit changes
  MEANING: a comment that resolved `vh`, `svh`, `lvh` and `dvh` against
  `window.innerWidth` instead of `innerHeight` passed a token comparison
  clean, and fails now. Same for `COMPAT_PRESETS`: its reveal/pin split
  is checked against the fallback stylesheet, since a preset in the wrong
  group is a wrong claim about which module a consumer needs.
  No behavior change.
- The stylesheet-vs-list test above only checked the list against the
  sheet, so a rule added for a NEW preset would leave the source comment,
  README and /docs/ stale with everything green: added the inverse, every
  class the fallback stylesheet has a rule for is named by `COMPAT_PRESETS`
  or on a two-name exception list (`sv-deck` and `sv-reading`, which get
  fallback rules on purpose but are not entrance/pin presets). Proved red
  by adding a rule for a preset the list did not name.
- The README paragraph and the `src/compat/index.ts` header comment that
  carry the stamped `COMPAT_PRESETS` text sit inside a Markdown paragraph
  and a JSDoc block, neither of which can carry a `<!-- name:start -->`
  marker on its own line (that splits the surrounding prose, see
  CLAUDE.md's anchor-splice rule), so the span itself has no visible sign
  that it is generated: a sentence added by hand between "stylesheet for"
  and "(written without" in README.md, or between "the same presets
  README lists:" and " * Written without" in the source comment, is
  silently overwritten by the next `demo:sync`. The CI gate still catches
  the resulting diff, but its message will not say why. Noted here for
  whoever lands on that diff next.

### Testing (round 7 follow-up, ADU-177)
No behavior change: a stub modelled a ResizeObserver that never delivers,
which left the mount-time schedule untested rather than passing.
- Every `ResizeObserver` stub in the slider suite (18 of them, one per
  fixture) was an empty `observe() {}` that recorded nothing and delivered
  nothing. A real one delivers once, on its own, right after `observe()`,
  with the element's current size, and `src/core/slider.ts` is written
  against that: its callback is `schedule()`, so the frame that measures a
  rail whose box was not ready at mount comes from that first delivery and
  from nothing else. One shared stub now delivers it, batched into one
  callback per frame, dropped by `disconnect()`, the way the spec has it.
  The delivery is a frame, not a microtask: a real one runs in the
  rendering step, and these fixtures drive their own rAF queue as their
  only clock, so a microtask would land after the whole test body had run.
  `disconnect()` cancels the queued frame instead of only forgetting it,
  or a fixture's blunt `cancelAnimationFrame` (every one of them clears the
  WHOLE queue) would leave the stub believing a delivery is still pending
  and unable to ever schedule another.
- All 18 fixtures now reach that delivery, up from 8: two drove
  `requestAnimationFrame` as `() => 1`, which throws the callback away and
  with it any observer delivery; seven never ran a frame at all; and one
  (`rapid next() clicks accumulate through the pending target`) ran six
  frames of its own glide and still delivered nothing, because the second
  `next()`'s `stopGlide` calls `cancelAnimationFrame`, which every fixture
  defines as clearing the WHOLE queue, wiping the already-queued delivery
  along with it. A new test covers what the missing delivery hid, a rail
  mounted with no box (a tab just revealed, a font not yet swapped): the
  synchronous mount measure reads a zero viewport and parks on slide 0,
  and only the observer's first delivery re-measures it onto the real
  active slide. Proved red against the old do-nothing stub.
- The canvas harness's stub had the same hole in a different spot. It
  queues the first delivery on `observe()` now and `pump()` runs it after
  that frame's rAF callbacks, Chrome's order: that frame order is what
  keeps the other 51 canvas tests correct, not `deliverResize()` also
  cancelling the pending flag on a fixture's own hand-fired `env.resize()`,
  which silently cancelled the observer's own queued mount delivery
  instead of letting it land. Removed: the canvas cases this fix targets
  only turn red under a delivery synchronous with `observe()`, never under
  one deferred a frame like this stub's. A hand delivery can now be
  followed by a real, same-box auto delivery on the fixture's next
  `pump()` (five of the 52 canvas fixtures reach that state, up from the
  one below), harmless because nothing moved between them. The new test
  mounts and pumps with no `resize()` call at all: the canvas sizes itself
  from the first delivery and starts its loop, and stays at the HTML
  default 300x150 without it.

### Testing (round 6 follow-up, ADU-161)
No behavior change: three fixtures modelled a browser state no engine is in,
which is what let two rounds of tests pass on broken code.
- The canvas suite's `compat()` fixture claimed an engine with no
  ResizeObserver and no IntersectionObserver (Safari below 13.1 and 12.1)
  while answering `CSS.supports()` like Chrome 104 and reporting a computed
  `aspectRatio` like Chrome 88. It is one engine now, Safari 12: the fallback
  stylesheet path runs, `<html>` takes `data-sv-compat`, and the CSSOM has no
  `aspect-ratio`. A new test covers what that mix hid, `compat()`'s
  ResizeObserver shim driving an UNSIZED canvas below the aspect-ratio floor:
  width pinned at `w0`, no ratio written where it cannot take effect, and a
  laid-out height stable over five viewport resizes instead of the ADU-158
  runaway. Proved red against that defect.
- The driver suite ran with no global IntersectionObserver, so `culler` was
  null and every entry stayed permanently `near`: 175 of its 184 assertions
  never touched the offscreen culling path (the other 9 sit inside the two
  tests that already drove their own IntersectionObserver by hand).
  IntersectionObserver ships before
  ResizeObserver in every engine (Chrome 51 vs 64, Firefox 55 vs 69, Safari
  12.1 vs 13.1), so the file now installs one that delivers an initial record
  per observed target and one per crossing, after that frame's animation
  frame callbacks, the order the HTML rendering steps run them in. Every
  existing assertion still holds. A new test drives a real cull through that
  observer, so an always-intersecting stub cannot come back unnoticed.
- The slider suite installed a ResizeObserver at 18 sites and a
  MutationObserver at one, though MutationObserver is from 2012 and
  ResizeObserver from 2018, which left the re-render resync
  (a childList record re-observes the slides and re-measures) off for the
  whole file bar one test. It is file-wide now, delivering only when a
  fixture actually replaces a child, and `destroy()` disconnecting it is
  asserted rather than assumed.

### Toggles (round 7 follow-up, ADU-172)
- A trigger nested inside two `toggles()` scopes is now toggled exactly
  once per click, by the nearest scope. `toggles(root?)` is public
  two-argument API and the documented setup runs two instances at once:
  `<ScrollVarsBoot />` calls it unscoped while a consumer calls it on
  their own root. A trigger inside both was contained by both, since
  containment is inclusive rather than nearest-exclusive, so both
  instances flipped the same class on one click and the toggle netted to
  nothing: after one click and after two clicks alike the panel was
  closed, `--sv-state` was 0 and `aria-expanded` was false. The user saw
  a button that does nothing. The first instance that acts on a click now
  claims it and every later one bails, which is the nearest scope by
  dispatch order: a scope containing the trigger is an ancestor-or-self
  of it, so it sits on the event's bubble path, and that path runs inner
  to outer. Two unscoped instances (the same bug without nesting) tie on
  registration order and still toggle once. The claim is keyed by the
  event object and taken on action rather than on sight, so a scope that
  cannot resolve the trigger's target still passes the click on to a
  wider scope that can, and `stop()` needs no bookkeeping: the claim lives
  exactly as long as the event object does, and a re-dispatched Event
  object is a silent no-op. One consequence, and the price of single
  ownership: only the owning scope's sync runs, so a duplicate trigger of
  the same target and class outside that scope keeps its previous
  aria-expanded until a click the wider scope owns. Pre-existing since
  scoped `toggles()`, not a regression. No public surface change.

### Docs (round 7, ADU-170)
Docs read against the code merged by the five round-7 code tickets.
- README's and the docs page's below-the-floor paragraphs no longer say
  the pin net keeps the stage in flow and nothing clipped unconditionally:
  that guarantee holds only without `compat()`. With it installed
  (ADU-168) the stage stays pinned so the module's fallback can keep
  animating the curtains and rail, and content taller than the stage
  clips there, measured on a four-card `sv-deck` with cards three and
  four unreachable past the clip and about 1800px of scroll doing nothing
  visible. Skipping `compat()` on that page is the escape.
- `data-sv-compat`, the marker `compat()` writes on `<html>`, is now
  documented next to `html.sv-on` and `data-sv-off` in README, and in
  AGENTS' browser support paragraph.
- AGENTS and llms.txt gained README's `sv-rail` below-the-floor exception
  (its track stays unwrapped and can run past the viewport edge); they
  had the general "static but 100% visible" claim without it.
- `--sv-pin-offset` is documented for what resolves today: px, rem, em,
  vh (svh, lvh and dvh resolve like vh) and vw. `calc()` reads as 0 and
  `vmin`/`%` are read as if they were px; full length resolution is
  ADU-100. Round 5 asked for this and ADU-159 missed it.
- AGENTS' slider breakpoint map now says a raw min-width key must be a
  bare number (`900: 4`): a string with a unit (`'900px'`) is coerced
  with `Number()` into `@media (min-width:NaNpx)`, an invalid query that
  never matches. Repeat of round 6's row 8, untouched by ADU-159's docs
  pass.
- `toggles()` is documented for exactly what it ships: a class,
  `--sv-state` and `aria-expanded`, no focus trap, Escape, `aria-modal`
  or tab semantics. AGENTS now points a real modal at the kit's `<Modal>`
  instead of implying `toggles()` alone covers it.
- AGENTS' SSR section no longer claims `<Scenes>` cannot hold RSC-only
  children: the slot pattern (pass already-rendered server content down
  as a prop, close over it in the client render function) still works,
  the same trick that lets any client component host RSC `children`.
- AGENTS' repo layout no longer claims most of `test/` runs with no DOM
  via `renderToStaticMarkup`: that helper backs only 3 of the 15 test
  files. It now says what the rest use, hand-rolled element and global
  stubs, plus `test/react.test.mjs`'s fake DOM for `react-dom/client`
  (ref and effect tests) and `test/canvas.test.mjs`'s canvas/observer
  stubs.
- README's pin-helper paragraph and its hand-kept twin, the `track()`
  comment in `src/core/driver.ts`, said the wrapper returns to flow
  "under reduced motion, or below the individual-transform floor" with
  no `compat()` qualifier, the same unconditional claim already fixed in
  the table row and the paragraph below it. Measured with `compat()`
  installed: the wrapper keeps `height: 320vh; position: relative` and
  the stage stays sticky, clipped. Both now read "below the
  individual-transform floor without `compat()`".
- AGENTS' browser-support paragraph dropped a leftover `(html.sv-on
  guard)` parenthetical on the below-the-floor-without-`compat()`
  sentence: that guard is `html:not(.sv-on)`, scoped to the no-JS case,
  not to below-floor-with-JS, which `pin.css`'s own
  `@supports not (translate: 0)` net covers instead.

### Compat (blind review round 7, ADU-168)
- `compat()` marks `<html>` with `data-sv-compat` when it installs its
  fallback stylesheet, and the two below-the-floor releases added in round 6
  now stand down on that marker. Each was right on its own for a page that
  calls no `compat()`: `styles/pin.css` released `.sv-stage` under
  `@supports not (translate: 0)` (ADU-149) and the pin helper stopped writing
  the tall wrapper height there (ADU-158), so a pinned section renders at its
  natural height instead of two blank viewports. Neither asked whether
  `compat()` was installed, and the fallback sheet has no stage rule of its
  own, so with the module present `--sv-pin` jumped from 0 to 1 over a single
  pixel and the `sv-curtain-l`, `sv-curtain-r` and `sv-rail` fallbacks snapped
  instead of animating, which is the one thing the module exists to do. The
  marker is the only new surface: an attribute on the root element, written
  next to the `data-sv-compat` attribute the injected `<style>` already
  carried.
- The cost, stated plainly: this is a trade, not a free win. On a page that
  calls `compat()` the round-6 releases no longer fire, so below the
  individual-transform floor the stage keeps `position: sticky`, `100vh` and
  `overflow: hidden`, and stage content taller than the stage clips again,
  which is exactly the defect ADU-149 measured in Chrome and fixed for
  everyone. The presets that module exists to animate are the ones that need
  the skeleton, and a page that would rather have the flow layout than the
  fallback animation gets it by not calling `compat()`: without the marker
  both halves release as they did in round 6.
- Size, measured: the marker rule's comment in `styles/pin.css` is trimmed
  to what a reader of that file needs, 122 bytes gzipped instead of 155,
  which takes the file's stamp from 3.2 back to 3.1 KB and `styles.css`
  from 9.0 to 8.9 KB. The rules themselves cost 69 bytes gzipped.

### Presets and no-JS (blind review round 7, ADU-168)
- Every released guard in `styles/pin.css` gained the twin that fires when the
  tracked element IS the preset element: `.sv-curtain-l[data-sv-off]`,
  `.sv-curtain-r[data-sv-off]`, `.sv-rail[data-sv-off]`,
  `.sv-deck[data-sv-off]` (and its `> *`), `.sv-reading[data-sv-off] > *`,
  `.sv-range[data-sv-off] > *` and `.sv-counter[data-sv-off]`. `data-sv-off`
  lands on the element whose tracker stopped, and a descendant-only guard asks
  for it on an ancestor, so a nested tracker on a `.sv-deck` kept its cards
  piled in one grid cell with the clock already gone, where the same markup
  without JS gives a plain block. `.sv-stage[data-sv-off]` carried the shape
  alone since round 5.

### Driver (blind review round 7, ADU-168)
- A motion-preference change now reads every pinned entry's computed
  position first and writes all the skeletons after, instead of reading and
  writing one entry at a time. The reversed order inside the helper made
  each read flush a style recalc of the write just made, against the
  README's "inside the driver, layout thrashing is impossible by
  construction". Ordering the read before the write is only half of it: the
  height cannot change the same element's computed position, so that read is
  free, but the write on one entry does invalidate the style the next
  entry's read asks for. Instrumented in Chrome with three pinned entries
  and a real preference flip, the per-entry order was read p1, write p1,
  read p2, write p2, and so on, so 2 of the 3 reads still landed with
  another entry's inline write pending: the reorder alone took N flushes to
  N-1, the hoisted read pass takes them to one. The position is read fresh
  on every pass and never cached on the entry, since an author media query
  can hand the element a `sticky` a stale read would overwrite with
  `relative`.
- Size, measured, because these are published numbers: the read pass takes
  the core entry (`scrollvars`, min+gzip) from 6.4 to 6.5 KB, `track` +
  `scan` from 3.6 to 3.7 KB and the headline typical page from ~5.1 to
  ~5.2 KB. The stamps read 6.6 KB once round-3's own driver work is merged
  in, and the stamped bundle ratio against gsap + ScrollTrigger stays ~7×.

### Gallery (blind review round 7)
- Every paste-the-preset CSS tab now carries the reduced-motion override its
  stylesheet ships, placed after the rule it overrides. Eight tabs
  (staggered reveal, deck spread, curtain, horizontal rail, sequenced scrub,
  split reveal, rotating words, marquee) quoted the animating rule and
  stopped before the sheet's `@media (prefers-reduced-motion: reduce)` block,
  so a reader who pasted them got a reveal that still transitioned and a
  marquee that never stopped. Importing the stylesheet was always fine; only
  the pasted copies were short.
- The Curtain, Horizontal rail, Sequenced scrub, GSAP and Three.js CSS tabs
  use the pin helper the other tabs already used: `data-sv-pin="250vh"` (or
  `pin: '250vh'` in JS) plus `.sv-stage`, instead of an invented
  `class="outer"` / `class="sticky"` skeleton whose height and stickiness
  lived only in HTML comments next to a valueless `data-sv-pin`. Pasted as
  shown, those four effects pinned nothing at all.
- Two new gates keep both true: every CSS tab that quotes a preset rule must
  quote that preset's reduced-motion override and keep it below the rule it
  beats, and every pane of a pin effect must carry a length on the helper and
  a real sticky stage. The three tabs with no installed component to compare
  against are now also loaded in Chrome twice, under no preference and under
  reduce, so the override is proved to win rather than merely to be present.
- The Horizontal rail and Sequenced scrub CSS tabs quote the shape
  `styles/pin.css` ships, not a simplified one. The rail spelled its start
  offset as a literal `100vw` where the sheet reads
  `var(--sv-rail-start, 100vw)` twice in the same declaration, so a reader
  who pasted the tab and then set the documented knob got nothing: at a 600px
  stage the preset travels 600 to -32, the pasted copy 1200 to 0. The
  sequenced-scrub tab declared `--sv-clock` on `.sv .sv-range > *`, where the
  sheet puts it on `:where(.sv .sv-range)` at zero specificity precisely so an
  author rule on the container wins; with such a rule the pasted copy rendered
  a child at opacity 0 that the preset renders at .5.
- The three gates of the round above now hold what they claimed to: the pin
  helper's ATTRIBUTE is held to a length like the JS option already was
  (`data-sv-pin="true"` passed, and the driver drops an invalid length and
  leaves the wrapper at its natural height), the sticky-stage check reads the
  pane's markup with `<script>` blocks stripped (a `.sv-stage` inside a gsap
  selector satisfied it), and the source-order half anchors per class on the
  last reduce block that resets THAT class (anchoring on the pane's last block
  left everything above it unchecked, and sticky-steps ships two blocks with
  the second below the rule it beats by design).

### Click driver (ADU-169)
- `toggles()`'s click handler now requires the resolved trigger to be
  inside its own scope (`scope.contains(trigger)`).
  `event.target.closest('[data-sv-toggle]')` walks the real DOM past the
  scope's own root: a click on a scope-internal element with no
  `data-sv-toggle` of its own could bubble to an ANCESTOR trigger outside
  the scope, which this scope's own `sync()` (scoped to
  `scope.querySelectorAll`) then had no way to reach, so that outer
  trigger's `aria-expanded`, and every sibling trigger of the same
  (target, class) pair, never updated even though its target class and
  `--sv-state` did flip. Successor of ADU-152, which widened
  `trackPointer`'s match the same way without widening this module's own
  containment check.

### Pointer (ADU-169)
- `trackPointer()` clears every element it has written `--mx`/`--my` to on
  handover, not just the single most recently written one. ADU-152 widened
  `matchIn` to accept nested and self matches (a `.sv-tilt` inside another
  `.sv-tilt`); a single remembered `last` element could not represent that
  handover, so the element being left behind was written once and never
  touched again, not even by `destroy()`, which also only ever cleared
  `last`. A real `pointerout` cannot cover this either: matchIn/onOut's own
  containment check treats a move onto a contained descendant as still
  hovering the same widget. The driver now tracks the full set of written
  elements and relaxes (`sv-pointer-leave`, `--mx`/`--my` back to `0`)
  whichever ones a move's new match does not include, and `destroy()`
  clears whatever is left in that set instead of one element.
- Audited every other ancestor-walking call in `src/` for the same shape
  (a scoped module resolving past its own root): `slider.ts`'s two
  `closest()` calls (native-control detection under a press, and
  focus-restore after a non-drag press) are both deliberately unscoped,
  mirroring real unscoped browser focus/activation behavior rather than
  resolving ownership of a JS-scoped instance, so they are not the same
  bug. `driver.ts`'s `clearReleased()` walks every ancestor up to the
  document root by design (it is a singleton engine with no scoped-root
  concept to violate). `debug/index.ts` reads one direct `parentElement`,
  not a walk. `src/react`, `src/canvas`, `src/compat` and `src/core/scan.ts`
  have no ancestor-walking call at all.

### Presets (round 7, ADU-166)
- `.sv-marquee-track` under `prefers-reduced-motion: reduce` no longer
  leaves `width: max-content` inside `overflow: hidden`: everything past
  the first screen was unreachable for a reduce user while everyone else
  saw the whole strip. The reduce block now wraps the track
  (`width: auto; flex-wrap: wrap`, matching the rail's own reduce-mode
  wrap in `styles/pin.css`), a genuine accessibility fix, and hides the
  duplicate copy. Measured, the duplicate already carried `inert` and
  reached the accessibility tree with zero extra nodes before this fix:
  hiding it is a visual correction, not an accessibility one, without it
  the duplicate's children wrap into view and the marquee's height goes
  from 96 to 240px.

### Presets, React and Gallery (round 7 follow-up, ADU-171)
- The marquee's duplicate copy is hidden by a class, not by an inline
  style: `styles/ui.css` now declares
  `.sv-marquee-track > .sv-marquee-dup { display: contents }`, reset to
  `display: none` under `prefers-reduced-motion: reduce` with no
  `!important` needed. The React `Marquee` (`src/react/index.tsx`) and
  the gallery's vanilla markup (`scripts/fx-data.mjs`) drop the inline
  `style="display: contents"` on the duplicate and carry the class
  instead.
- The reduce rule targeted `.sv-marquee-track > [aria-hidden]`, matched
  by attribute presence: a consumer's own direct child of the track
  carrying `aria-hidden="false"` was hidden too. The selector is now
  `.sv-marquee-track > .sv-marquee-dup`, which only ever matches the
  duplicate.

### Tooling (blind review round 6)
- Git pushes no longer create Vercel deployments. Production is deployed by
  `npm run demo:deploy`, which runs `vercel deploy --prod` from `demo/` and
  reads `demo/vercel.json`, so the public site is unaffected. Every push of a
  work branch used to create a preview deployment that the project's
  ignored-build-step cancelled straight away, and a cancelled deployment
  still counts against the account's shared daily quota: 61 of the last 100
  deployments were cancelled scrollvars builds, which exhausted the
  allowance and blocked another project's production deploys.

Blind review round 3 (Codex gpt-6-astra on commit 677656b): the CSS
enhancement contract holds in every documented case. Second pass (verifier
findings on the same round): three more defects fixed.

### Driver
- `track()`'s returned untrack is identity-guarded: it deletes and unobserves
  only if it is still the current entry for that element
  (`entries.get(el) === entry`), so tracking the same element twice and
  calling the first untrack no longer deletes the replacement. An explicit
  untrack now also removes `sv-live` and every variable that entry wrote
  (plus `--sv-scenes`); `.sv` stays, and the `once` fire-and-forget path
  keeps `sv-live` (its self-delete already left the entry map, so the guard
  makes the later untrack call a no-op there, which is the intended
  behavior).
- `init()` is transactional: it constructs the `ResizeObserver` before
  installing any listener, so a throwing constructor leaves nothing to undo.
  `track()` checks `initialized` after calling `init()` and returns a no-op
  (no entry, no pin helper, no schedule, no `.sv` class) when it failed,
  so the page stays static until `compat()` shims a `ResizeObserver` in and
  a later `track()` call retries `init()` clean.
- `refresh()` now forces one geometry pass through every entry on the very
  next frame, including culled ones (`near === false`): content changes that
  do not fire a resize (an accordion opening, an image swapped for a taller
  one) previously left offscreen trackers stale until they scrolled back
  into the culling margin.
- A tracked element with a `root` now measures against `root.clientTop` and
  `root.clientHeight` instead of the root's bounding rect, so a bordered
  scroll container shares one origin between `track()`'s pin math and
  `scrollToScene()`'s scroll target. `track()` also observes the `root`
  with the `ResizeObserver`, so a resize of the scroller itself
  reschedules a measure.
- `--sv-pin-offset` now resolves `rem` (root font-size), `em` (the
  element's own font-size), `vh`/`svh`/`lvh`/`dvh` (`window.innerHeight`)
  and `vw` (`window.innerWidth`) to pixels; a bare number still reads as px.
- `--sv-page` and `--sv-v` skip the style write when the serialized value
  did not change from the previous frame.
- Second pass (verifier findings on 00436db): `track()` on an element that
  is already tracked replaced its entry in place, which left the identity
  guard on the first entry's untrack blocking forever, so a variable only
  the first entry ever wrote (`--sv-t` from `{travel: true}` followed by
  `{}`) stayed inline. A replacing `track()` now releases the previous
  entry's outputs first (its written vars, `--sv-scenes`, its pin helper,
  both observers), so it is exactly untrack then track. A root element
  that is also tracked standalone lost its `ResizeObserver` watch the
  moment the standalone entry was untracked, because `unobserve(el)` fired
  for the shared element with no regard for the entries still using it as
  their `root`. `untrack()` now checks whether any other live entry still
  needs that element watched, either as its own tracked element or as its
  `root`, before unobserving it.
- Third pass (verifier finding on 130395a): the `once` fire-and-forget branch
  in `apply()` still unconditionally unobserved its own element on the
  ResizeObserver when it self-released, bypassing the `stillNeeded()` guard
  the second pass added elsewhere. An element that is both a `once` entry
  and another entry's `root` lost that other entry's resize watch the
  moment the `once` entry went live and settled. The branch now checks
  `stillNeeded()` before unobserving, same as `releaseEntry()`, but keeps
  settling `--sv-view` and latching `sv-live` itself: it still does not
  route through `releaseEntry()`.
- Fourth pass (verifier finding on 293b1dc): the third pass guarded the
  `once` branch's unobserve of its own element, but never released its own
  `root`. A `{ once: true, root }` entry that self-released kept the
  root's `ResizeObserver` watch forever, since `releaseEntry()` is never
  called on that path. The two guarded unobserves (the tracked element and
  its `root`) are now one `unobserveIfUnneeded()` helper, used by both
  `releaseEntry()` and the `once` branch, so the two paths cannot drift
  apart again.
- Fifth pass (verifier findings on PR #13 / ADU-130, and a panel pass): `apply()`
  kept writing `--sv-view` and `--sv-t` inline, and fired one extra
  `onTravel`, after an entry's own `onLive` untracked or re-tracked that
  same element from inside the callback. `apply()` now re-checks
  `entries.get(entry.el) === entry` right after `onLive` returns, before
  any of the writes and callbacks that follow, and skips them all when the
  callback changed the entry's identity.

### Presets and no-JS
- `.sv-split` word/char spans compute to `display: inline-block`, so
  `sv-split-rise` can actually apply `translate` to them (non-replaced
  inline boxes ignore it).
- The reduced-motion override for `.sv-auto` now matches the same
  `:not(.sv-skip)` compound as the normal entrance rule, so it wins on
  specificity instead of losing to it.
- The no-JS guards for curtain, rail, deck, reading, range and counter now
  match `[data-sv]` as well as `.sv`: markup that has not been scanned yet
  (data-sv only, no JS run) no longer leaves curtain panels as absolute
  overlays over the revealed content.
- `sv-spread` and `sv-acts` are driven from the inherited `--sv-live` flag,
  like the entrance presets: a nested tracker that is not itself live no
  longer inherits a live ancestor's spread or acts clock.
- `.sv-acts.sv-open` now also outranks the live-driven rule
  (`.sv-acts:not(.sv-open)` on both selectors): an opened widget sitting
  inside, or itself, a tracker that is not live keeps its finished
  `--sv-act` instead of being reset to 0.
- `.sv-auto > :nth-child(1)` (and `.sv-stagger`) resets `--sv-order` to 0,
  so the first child never inherits an ancestor's order.
- `.sv-slider.sv-cols` also matches `.sv-cols .sv-slider > *`, so a
  `className="sv-cols"` on the Slider shell (one level up from
  `.sv-slider`, where React's `className` prop lands) works too.
- Blind review round 5 (Astra on 7992458), findings 7a and 7b. The
  reduced-motion overrides for `.sv-spread > *` and
  `.sv-tilt.sv-pointer-leave` lost their specificity fight against the
  animating rules they are meant to override (`.sv-on .sv
  .sv-spread.sv-spread-in > *` at four classes, `.sv-tilt.sv-pointer-leave`
  at two, both outranking the media query's plain one-class selector), so a
  live preference switch animated the reset instead of snapping to it.
  Same fix as the `.sv-auto` one above, missed on these two presets: each
  override now also carries the animating rule's own selector shape inside
  the media query, ties on specificity and wins on source order.

### Click driver
- `toggles()` now marks `sv-ui` on the element it actually controls (the
  resolved `data-sv-target`, or the trigger itself when there is no
  target), not on `<html>`. Marking `<html>` unconditionally meant an
  unrelated scroll-revealed `sv-acts` widget on a page without a booted
  scroll driver was wrongly exempted from the no-JS finished-state guard
  and stayed hidden at act zero forever. The guard is now
  `html:not(.sv-on) .sv-acts:not(.sv-ui)`: scoped to the widget itself. A
  target added to the DOM after boot gets `sv-ui` on its first click, so
  that first click shows the finished state with no visible transition.
- Third pass: marking a boot-present target `sv-ui` now holds its inline
  `transition` at `none` for two animation frames. Without that, a target
  closed by default (`.sv-acts` without `.sv-open`) settled from the no-JS
  finished value down to 0 with the acts transition still running, a
  visible un-animation the instant `toggles()` took over.
- Fourth pass: that hold read and wrote the inline `transition` shorthand
  (save, set to `none`, restore). Two defects, both reproduced in Chrome:
  an inline transition longhand (e.g. `style="transition-duration: 400ms"`)
  reads back as `''` through the shorthand getter, so the "restore" erased
  it for good; and `transition: none` stopped every transition on the
  element for the hold, not just the acts one, snapping an unrelated
  in-flight transform transition. The hold now sets an internal
  `--sv-acts-settle` custom property to `0s` instead, new and additive:
  `.sv-acts`'s own transition reads its duration from it
  (styles/state.css), and `toggles()` never touches `style.transition`.
- Fifth pass: `--sv-acts-settle` only reaches the duration the stylesheet
  itself declares on `.sv-acts`. An element that also carries its own
  inline `transition-duration` LONGHAND (the fixture's `#longhand-target`,
  `style="transition-duration: 400ms"`) outranks that knob by cascade
  origin no matter what it is set to, so the settle for that element still
  played out over the longhand's own duration instead of 0s, reproduced in
  Chrome (`3, 2.876, ... 0` over 400ms): the exact un-animation this
  feature exists to remove. The shipped e2e case only asserted the
  attribute string survived and never sampled `--sv-act` on that element,
  so it stayed green. `toggles()` now also saves that inline longhand's
  exact value and priority, holds it at `0s` for the same two frames (or
  until a click inside the hold cancels it, restored immediately there
  too), and restores it exact, through the longhand getter/setter only,
  never the shorthand. A target without an inline longhand never has one
  written, so an unrelated in-flight transition on it is untouched. What
  remains unguarded: an author RULE, not an inline style, that overrides
  `transition-duration` or the whole `transition` shorthand on `.sv-acts`
  at higher specificity than the preset's own rule still owns the settle
  timing (styles/state.css).
- Sixth pass: the whole settle (`--sv-acts-settle` and the inline
  `transition-duration` hold) now only runs on `.sv-acts` targets, gated
  only on `sv-ui` before. `getPropertyValue('transition-duration')` cannot
  tell an authored longhand from the browser's own expansion of an
  unrelated inline `transition` shorthand, so a plain toggle target with
  one (this module's own `<nav id="menu">` example, most of the time) had
  it forced to `0s` for two frames regardless, snapping any change to it
  that landed inside the window instead of animating. A target that is not
  `.sv-acts` still gets `sv-ui`, nothing else.

### Compat
- `compat()`'s fallback stylesheet now covers `sv-deck` on engines missing
  individual transform properties: the pile unstacks into a static,
  non-overlapping layout instead of leaving every card in pin.css's shared
  grid cell.
- `splitParts` no longer uses `Array.prototype.flatMap` (missing on Chrome
  61-68 and Safari 11, the floor compat claims).
- Blind review round 4 (Astra on 7489a11), findings 3 and 4. The
  ResizeObserver stub's records now carry a `contentRect` and a
  `contentBoxSize`, not a bare `{ target }`: `mountEffect()`'s
  `measureLayout()` reads `entry.contentRect.width` directly and threw
  under the old stub. Both come from the layout box, like the native
  observer: `clientWidth`/`clientHeight` minus computed padding, never
  `getBoundingClientRect()`, which is the paint box and scales with an
  ancestor transform (under `transform: scale(2)` a 400x300 canvas
  reported an 800x600 content box and settled its backing store there
  forever). `contentBoxSize` is logical, so a vertical writing mode swaps
  `inlineSize` and `blockSize`.
- The fallback stylesheet's `sv-deck` rule bounded its `--sv-slice` with
  `max()` (shipped together with `min()`, above the floor README
  advertises) and had no plain declaration in front of it, so below that
  floor the whole `transform` was invalid and dropped, leaving every card
  stacked in pin.css's shared grid cell. The deck fallback no longer uses
  `max()`: it unstacks statically (`display: block` on the deck,
  `transform: none` on the cards) instead of animating. Every other
  `max()` in the sheet (only `sv-drift`'s fade) keeps the plain
  `opacity: 1` in front of it that old parsers fall back to, and is
  unchanged.

### Testing
- The no-JS "pin stages never cover their revealed text" e2e sweep now
  scrolls each candidate into view before measuring: 6 of the 7 pin fx
  pages were previously off-viewport at scroll position 0 and silently
  skipped. The sweep asserts and prints a minimum examined count per pin
  page, so a regression back to zero coverage fails it instead of passing
  by omission.
- The boot-settle e2e fixture (toggles-boot-settle.html) gained two more
  targets: one with an inline `transition-duration` longhand, one with an
  unrelated in-flight `translate` transition, proving the settle hold
  leaves both alone. Its post-click assertion now samples `--sv-act`
  across frames instead of only the final value, so a transition silently
  reduced to zero duration would fail it instead of passing by omission.
- Fifth pass: `#longhand-target`'s `--sv-act` is now sampled per frame like
  the main target, so a settle silently governed by the longhand instead
  of `--sv-acts-settle` fails the sweep instead of passing on the attribute
  string alone. A new `#longhand-important-target`
  (`transition-duration: 400ms !important`) proves the same hold and
  restore keep the original priority, not just the value. Unit tests cover
  the longhand hold-and-restore lifecycle exact (value and priority), that
  a target with no inline longhand never has one written, and that a click
  inside the hold restores it immediately alongside the knob.

### Driver (blind review round 4)
- Releasing a tracked element now settles it VISIBLE. `.sv` and `[data-sv]`
  both declare `--sv-live: 0`, only `.sv.sv-live` lifts it to 1, and
  `html.sv-on` is never taken back off, so `stopScan()`, a
  `ScrollVarsBoot` unmount or a route teardown used to strand every section
  that had not gone live yet at opacity 0 forever, and an option change
  flashed content out and back. Release writes an inline `--sv-live: 1`
  (rather than dropping `.sv`, because server markup keeps its authored
  `data-sv`, which hides on its own); `track()` removes that inline value
  first, so tracking hands the flag back to the class.
- The `pin: '320vh'` helper no longer keeps an authored inline
  `position: static`: only a non-static authored position is kept, and a
  static one (inline or computed) gets `relative`, so the containing block
  the helper promises really exists and an absolutely positioned curtain
  cannot escape the stage.
- The `once` fire-and-forget path deletes its entry by identity
  (`entries.get(el) === entry`) BEFORE invoking `onLive`, so a callback that
  tracks the same element again keeps its replacement instead of having it
  deleted out of the map.
- The write phase skips entries that are no longer in the map: an `onLive`
  or `onScene` earlier in the same frame can untrack another element, and
  that element no longer gets one more variable write and one more callback
  after its untrack returned.
- `scrollToScene()` scrolls with `behavior: 'instant'` under
  `prefers-reduced-motion: reduce` even when the caller asked for smooth,
  mirroring the slider's glide. `useScenes().goTo` routes through it, so
  React scene navigation honors the preference too.

### Canvas
- `mountEffect()`'s `applySize()` stops the unsized-canvas DPR feedback loop
  (ADU-107): a canvas with no CSS width/height lays out at its own
  backing-store size, so writing `canvas.width`/`height` after every resize
  would otherwise feed straight back into the next resize, unbounded. Eight
  earlier passes (each one a verifier or panel finding, reproduced in real
  Chrome) all tried to catch that loop by measuring: a border-box-rect-vs-
  content-box-attribute equality guard that both missed a bordered unsized
  canvas and mispinned a legitimately CSS-sized one; a padding-inflated
  read; a fractional-padding rounding residual; a fallback re-measure that
  disagreed with a bit-exact ResizeObserverEntry for reasons that had
  nothing to do with feedback (fractional padding, a transform); a flat 1px
  tolerance that took dozens of passes to notice a small canvas's small
  move; a ratio check whose `dpr > 1` guard missed a zoomed-out page
  entirely and whose transform-inflated fallback read mispinned a padded,
  transformed canvas; a value- and timing-based "echo window" (matching a
  later ResizeObserverEntry's `contentRect` against the exact size just
  written, within two animation frames) that could not tell a genuine CSS
  resize landing on that same number apart from its own echo (a 300x150
  canvas at dpr 2, doubled to 600x300 by a class applied a frame after
  mount, lands exactly on the 600x300 the harness itself had just written),
  because a comparison of two numbers never asks WHY they match, only THAT
  they do; and (eighth pass) a causal probe that bumped `canvas.width`/
  `height` up by a flat `+1` EACH and read `canvas.clientWidth`/
  `clientHeight`, pinning both `style.width` and `style.height` together
  whenever either axis followed. That probe was genuinely causal, not a
  coincidence check, but three more verifier findings on it (all reproduced
  in real Chrome) showed it asked the wrong shape of question: a flat,
  per-axis `+1` perturbs the RATIO between width and height, not just their
  size, so an ordinary `width: 100%; height: auto` canvas (no CSS height at
  all, the auto height derived from the intrinsic ratio) could read as
  having moved on height and get wrongly pinned, at dpr 0.5, 0.8 and 1.25;
  pinning both axes together assumes both need it, so a `max-width: 100%`
  canvas not yet at its cap correctly got pinned on mount, but a later
  container shrink that engaged the cap left the pinned height frozen,
  distorting a 150x150 box instead of scaling it to 150x75; and a bare
  `<canvas width="300" height="150" style="max-width: 400px">` at dpr 2 had
  its backing-store write (600) clamped by the cap (400) before the probe
  ever ran, so it read no follow at all and settled visually inflated at
  400x200, never pinned to its true, uncapped 300x150.
  The ninth-pass design narrowed the question: PROPORTIONAL, and about ONE
  axis. Right after `applySize()` writes the backing store
  (`canvas.width = W`, `canvas.height = H`), it perturbed both attributes
  together by the same factor (so the ratio between them holds steady) and
  forced one layout read (`canvas.clientWidth`), then restored `W`/`H` and
  read again. If the two readings differ, WIDTH follows the attribute
  (unsized on width, whatever height does, which is what catches the
  `max-width` cases above); if they are equal, width is CSS-sized, whatever
  its literal source (a percentage, a fixed px value, or itself derived
  from a fixed CSS height through the intrinsic ratio), and nothing about
  height enters that conclusion. Only WIDTH gets pinned, to the CSS content
  width `applySize()` measured right before the write, forcing
  `box-sizing: content-box`; height is left alone, deliberately, so it
  keeps deriving from the intrinsic ratio exactly as this harness's own
  proportional writes keep it stable, and a later `max-width` shrink
  correctly recomputes it from the new width instead of fighting a frozen
  number. The ninth pass chose `Math.floor(W / 2)`/`Math.floor(H / 2)`
  (halving) as that factor.
  Tenth pass, two more verifier findings on the ninth pass, both reproduced
  in real Chrome. Finding 1: `Math.floor` on a halved value does not
  preserve the ratio between `W` and `H` when they have different parity
  (one odd, one even), so a fixed-CSS-height, auto-width canvas (the mirror
  case: the auto width derives from the fixed height through exactly that
  ratio, e.g. `style="height: 101px"`) could have its floored ratio read
  back a fraction off the true one, flipping the before/after comparison
  even though the CSS height never moved; swept across heights 99-151 and
  dprs 0.5-2, this hit 27 of 42 combinations, wrongly pinning an ordinary,
  fully-responsive canvas and then distorting it on the next CSS height
  change. Finding 2: `<canvas style="max-width: 100px">` with the default
  300x150 attributes settles unpinned with a crisp backing store (the cap
  scaled by dpr) at every DPR above 1, which is CORRECT, not inflation: a
  cap at or below the canvas's natural size binds before the harness ever
  touches the attributes, so the box is already the cap's own size, and
  pinning it would be the bug, not the fix (the opposite of a bare
  `max-width: 400px` above the natural size, which stays the inflation
  case and must still be pinned). The probe now tries GROWING first
  (double `W`/`H` together: exact for any pair of integers, whatever their
  parity, which is what fixes finding 1) and, only if that shows no follow,
  also tries SHRINKING (halve `W`/`H` together, exact only when both are
  even, so it can never reintroduce finding 1's flooring bug) to catch a
  cap already binding on the just-written, dpr-inflated attribute that
  growing alone cannot reveal (growing further only stays behind a cap
  already behind it). At or below dpr 1, a cap at or below the natural
  size is a narrower guarantee: below 1 the just-written attribute can
  itself land below the cap, a genuine risk (the same unbounded loop this
  module exists to stop, just shrinking instead of growing), correctly
  pinned; at exactly dpr 1 the attribute lands exactly on the cap,
  genuinely stable, but the shrinking probe cannot tell that apart from
  just above it and pins here too, a narrow, documented, harmless over-pin
  (the pin lands on exactly what the cap already renders). Growing is
  skipped past the browser's own canvas-size limit (8192: doubling could
  itself trip it), and shrinking needs even parity, so a giant canvas with
  an odd dimension on either axis has no safe direction to probe in at all
  and is treated as already sized.
  A canvas with a genuine CSS size on both axes is never pinned and always
  follows a later resize, whatever its border, padding (integer or
  fractional), transform, or the current device pixel ratio (above 1,
  below 1, or exactly 1). Cost: two forced layouts per probe run, up to
  four per resize event when growing alone does not already decide it,
  never per animation frame, measured against real Chrome to settle in
  exactly one `resize()` callback for most size/DPR pairs, the pin running
  synchronously inside the same callback that delivered the entry, before
  the browser ever renders the unpinned intermediate box.
  One remaining trade-off, documented in the module doc: a canvas with no
  CSS size on either axis still gets its width pinned inline by the harness
  (give it real CSS dimensions to keep control of its own size).
  Eleventh pass, two more verifier findings on the tenth pass, both
  reproduced in real Chrome. Finding 1: the tenth pass's probe ran AFTER
  the backing-store write and rounded width and height independently every
  pass, each from whatever the LAST pass had already produced; a
  mirror-case canvas (fixed CSS height, auto width) has its free width
  computed by the CSS engine through the intrinsic ratio, which IS this
  harness's own backing-store attributes, so independent rounding nudged
  that ratio a fraction every pass and the error compounded instead of
  settling: swept at dpr 0.5 this took 51 to 77 ResizeObserver passes to
  reach a WRONG fixed point (a square box instead of the true 2:1 one), and
  at dpr 0.8 it never reached one at all (a 103px fixed height diverged to
  a 422 backing height instead of the true 206). Finding 2: the same
  post-write probe timing perturbed the just-written, DPR-scaled attribute,
  so at a DPR that does not divide evenly (roughly 1.2 to 1.9) a first pass
  could already write a backing store past a `max-width` cap before the
  probe ever ran against the canvas's true, natural size, and a second
  pass's probe then perturbed that already-inflated value and pinned at the
  inflated number instead of the true one (`max-width: 100px` false-pinned
  across that range; a bare `max-width: 400px`, natural size 300x150,
  pinned at an inflated 450x225 at dpr 1.5 instead of the true 300x150).
  The design anchors every pin, and every free-axis derivation, to values
  captured on the very first `applySize()` call (mount), before this
  harness ever writes anything: the CSS content size the author's own CSS
  and attributes already produced (`anchor`), and the ORIGINAL width/height
  attribute ratio (`ratio0`, from `w0`/`h0`). The causal probe now runs
  BEFORE this pass's own write, on `w0`/`h0` specifically, never on this
  harness's own evolving backing store, and never runs again once a canvas
  is pinned; when width follows, `style.width` is set to `anchor.width` and
  `style.aspectRatio` to `w0 / h0`, so the CSS engine derives height
  directly from the ORIGINAL attribute ratio from then on, never through
  this harness's own rounded backing-store attributes again (this also
  fixes the tenth pass's own documented sub-pixel residual on the free
  height axis: CSS `aspect-ratio` is computed exactly, not through a
  rounded attribute ratio). For a canvas that stays unpinned, a second kind
  of probe, two single-axis perturbations (width alone, height alone),
  decides which axis, if either, is ratio-derived from the other (the
  proportional, both-together probe cannot answer this on purpose: it never
  perturbs a ratio-tracking axis, which is what keeps it immune to the
  eighth pass's false-positive bug). That free axis is always computed from
  the OTHER, just-rounded axis and `ratio0`, never independently rounded
  from its own, possibly-drifted measurement, which is what stops Finding
  1's error from compounding. A dead band skips the whole pass, probe
  included, when the measured content size moved by less than 0.5 CSS
  pixels on both axes since the last write, unless the DPR itself changed;
  it absorbs the one small, self-induced residual the free axis's own write
  can still cause on its very next entry. Probing `w0`/`h0` instead of a
  DPR-scaled write fixes Finding 2's parity-mismatch inflation outright: a
  `max-width` cap at or below the natural size doubles or halves the SAME,
  never DPR-inflated pair every time, so it reads as CSS-sized across that
  whole range; a cap above the natural size still only starts to bind once
  doubling `w0`/`h0` pushes past it, and pins at the anchor, never an
  already-inflated write. It does not remove the tenth pass's dpr-BELOW-1
  case, and should not: at dpr 1 and above this harness's own write never
  drops the attribute below the cap, genuinely never pinned; below dpr 1 it
  can, unclamping the cap for real, a risk the mount-time probe (on
  `w0`/`h0`, never a DPR-scaled value, on purpose) cannot see coming. A
  separate escape check, right after computing each pass's own candidate
  write, reuses the same causal growth check against THAT candidate
  instead, and catches it there, still never over-pinning at exactly dpr 1
  the way the tenth pass did (a genuine improvement, not just a port of the
  old behavior). One narrow limitation remains in the mount-time probe
  specifically, inherited from its own halving fallback's exactness
  requirement: a cap strictly between half the natural size and the natural
  size itself can still read as a false follow if it changes AFTER this
  canvas was already found sized; documented, not fixed in this pass.
  Twelfth pass, two more verifier findings on the eleventh pass, both on
  its pin. Finding 1: the pin used `anchor.width`, the CSS content size
  MEASURED at the moment this canvas was judged unsized, which, for a
  canvas whose cap was already binding right then, IS the capped value,
  not the natural one (a `max-width: 120px` cap on a natural-300 canvas
  pinned at 120); fixed at that measured value forever, the box never grew
  back when the cap later widened, or a percentage cap's container grew, a
  symptom the eleventh pass's own "gap" limitation was really this bug
  wearing a different value. Finding 2: `style.aspectRatio` was set
  unconditionally to `w0 / h0`, silently overriding an author's own
  `aspect-ratio` (a square 300x300 canvas with `aspect-ratio: 1` snapped to
  2:1 on mount). The fix is simpler than the mechanism it replaces: an
  unsized canvas wants its intrinsic size, which is the attribute size in
  CSS px, `w0` by `h0`, and nothing measured. `style.width` is now pinned
  to `w0` always, never a measurement, so CSS caps clamp it exactly as they
  always clamp an intrinsic size, LIVE, at mount and on every later change,
  no different from an ordinary CSS-sized element under the same cap;
  `style.aspectRatio` is set to `w0 / h0` only when `getComputedStyle`
  reports a ratio that still starts with the `auto` keyword, so an
  authored ratio is kept. This removes the
  eleventh pass's "gap" limitation outright: it existed only because the
  pin froze at a measured value a cap could later escape, and pinned at
  `w0` there is no stale value to escape from. It does not remove the
  escape check (a DPR below 1 landing a pass's own candidate write below a
  cap the mount-time `w0`/`h0` probe cannot see because the cap sits below
  half `w0`, a separate blind spot, unrelated to the gap): a test proved
  that case still shrinks unboundedly without it, so it stays, retargeted
  to pin at `w0` like every other pin here instead of the candidate value.
  Also guards `ratio0` (`w0 / h0`) against a `width="0"` or `height="0"`
  attribute, which would otherwise make it 0, Infinity or NaN: such a
  canvas now skips the probe and the free-axis derivation entirely and is
  treated as CSS-sized, both axes rounded independently from the measured
  size. Stated plainly: an unsized canvas renders at its attribute size in
  CSS pixels, with a device-pixel backing store for a crisp bitmap; CSS
  caps and percentages still apply on top of that size, exactly as they
  would on any other element; give a canvas real CSS dimensions to size it
  any other way. Thirteenth pass, one verifier finding on the twelfth
  pass's own fix, live in Chrome: the `aspectRatio === 'auto'` guard never
  fired, for any canvas, authored or not. Chrome always reports a canvas's
  computed `aspectRatio` as `auto W / H`, the intrinsic width/height
  attributes appended to the keyword, never the bare `auto` string, so
  `style.aspectRatio` was never written and height kept deriving from this
  harness's own DPR-scaled attributes instead; whenever `w0 * dpr` was not
  already an integer, the rounding remainder reapplied to the current
  height on every later pass (`<canvas width="30" height="61">` at dpr 0.51
  reached a 33-million-pixel backing height after 668 passes). Fixed by
  testing `startsWith('auto')` instead of exact equality: an authored
  `aspect-ratio` computes to a bare number pair with no `auto` keyword at
  all, so the new check still fires only when the author left the ratio
  alone, and now actually fires.
- Blind review round 4 (Astra on 7489a11), findings 3 and 16.
  `measureLayout()` now falls back to its own `getBoundingClientRect()`
  measurement when a ResizeObserverEntry has no `contentRect` (a stub
  without one, not just compat()'s own, now fixed too), instead of
  throwing on `entry.contentRect.width`. A resize while `pause()` is
  active writes a fresh, blank backing store (`canvas.width = W` clears
  the bitmap) but left the tick loop stopped, so the canvas stayed blank
  until whatever resumed it; `applySize()` now paints one frame
  synchronously right after that write whenever the loop is not running,
  without starting it.
- Blind review round 5 (Astra on 7992458), finding 13 and doc row 4's
  canvas half. `getComputedStyle(canvas).aspectRatio.startsWith('auto')`
  threw a `TypeError` on any engine whose CSSOM has no `aspectRatio`
  support at all: the property is absent there, not an empty string, but
  the DOM lib types it as always a string, so nothing guarded the read.
  `pinAtW0()` threw out of both `applySize()` and `mountEffect()` on any
  such engine, below the README's own Safari 12.1 canvas gate. Fixed with
  an explicit `typeof` check ahead of `startsWith`, so a missing property
  is treated the same as `'auto'` instead of thrown on.
  `media.addEventListener?.('change', ...)` is a silent no-op on a
  `MediaQueryList` that only implements the deprecated
  `addListener`/`removeListener` pair (Safari below 14): both the
  DPR-resolution watch and the reduced-motion watch now fall back to it,
  symmetrically on mount and on `destroy()`.

### Installed components (blind review round 3)
- `StickySteps`'s `inert` spread now casts like the core does
  (`as unknown as Record<string, never>`): the previous inline ternary put a
  `string | boolean` into a `boolean` prop, failing `tsc` under React 19
  types. It also now subscribes to the `prefers-reduced-motion` media
  query's `change` event instead of reading it once, so a live switch drops,
  or restores, `inert`/`aria-hidden` on the stacked shots immediately.
- `GsapScrub` and `ThreeScene` declare their mutable refs as
  `useRef<T | null>(null)`, not `useRef<T>(null)`: read-only under React 18
  types. `GsapScrub` also drives the timeline through
  `prefersReducedMotion()` (imported from `scrollvars`), so a
  reduced-motion visitor gets the finished frame instead of a scrubbed one.
- `gsap-scrub` and `three-scene` declare `min: '1.13.0'`: the string pin
  helper and `.sv-stage` they both use are 1.13.0 features, not the
  1.9.0/1.11.0 previously declared.
- `curtain`, `horizontal-rail` and `pointer-tilt` declare
  `requires.tailwind: true`: their installed content leans on Tailwind
  utility classes with no component-owned CSS backing them. The CLI prints
  "Tailwind utilities: required" for these effects; the registry gains the
  `tailwind` flag.
- `CoverflowSlider`'s coverflow transform moved from an inline `style`
  object into a `.cf-slide` class with a `prefers-reduced-motion: reduce`
  override, matching the preset policy that scroll-linked transforms return
  to flow under reduced motion. The Tailwind tab of `hero-cinematic` gained
  matching `motion-reduce:` variants for the orb and the inner block.
- `TimelineScrub` renders the year as visually-hidden real text plus an
  aria-hidden counter span, instead of `aria-label` on a bare `<span>`
  (prohibited on generic roles, Axe `aria-prohibited-attr`). `StatsCountup`
  emits `<dt>` before `<dd>` (order was reversed), and renders the final
  value as visually-hidden text with the counter itself `aria-hidden`.

### Installed components (blind review round 3, second pass)
- The CLI component `tsc` gate was vacuous under a config-level error: a
  bad `moduleResolution` prints as `tsconfig.json(9,25): error TS6046`,
  which never matches the per-file `<name>.tsx(line,col)` regex, so every
  fixture reported "type-checks: pass" while tsc never actually checked
  any of them. The gate now counts every `error TS\d+` line in the raw
  output against the lines it can attribute to a fixture file and fails
  loudly, with the raw output, on any mismatch or on a non-zero exit with
  no per-file diagnostics. Proved red on a deliberately invalid
  `moduleResolution` before landing, green again after reverting it.
- `hero-cinematic`'s Tailwind tab: the `motion-reduce:` override for
  `.inner` sat on the `.inner` div itself (`[opacity:1]`/`[scale:none]`,
  a one-class selector, specificity 0,1,0) while the base rule reaches
  `.inner` through the section's `[&_.inner]:` variants (a two-class
  selector, 0,2,0), so the override never won and reduced-motion visitors
  still got the scroll-driven fade and scale. The override now lives on
  the section in the same `[&_.inner]:` shape, after the base variants,
  so equal specificity lets source order settle it.
- The condensed `react:` doc snippets for `timeline-scrub` and
  `stats-countup` referenced `<span style={SR_ONLY}>` without defining
  it, unlike every other self-contained snippet (`SR_ONLY` is not
  exported from `scrollvars`). Both now inline the sr-only style object
  literal at the point of use.

### Installed components (blind review round 3, third pass)
- The second pass's `tsc` gate fix counted every `error TS\d+` line against
  the lines it could attribute to a fixture file, but the ambient stubs
  `gsap.d.ts` / `three.d.ts` compile in the same scope (needed to
  type-check `gsap-scrub`/`three-scene`) and are not one of the EFFECTS
  fixtures the per-file loop asserts on: a syntax error injected into
  `AMBIENT_GSAP` attributed cleanly to `gsap.d.ts(line,col)`, so the count
  matched, the "not vacuous" meta-test passed, and all 17 fixture tests
  reported "type-checks: pass" while tsc had exited 1 the whole time. The
  gate now fails the whole test file on any non-zero tsc exit, no matter
  how the diagnostics are attributed, printing the raw output; per-fixture
  attribution stays for the nicer message. Proved red by injecting a
  syntax error into the ambient stub (the gate failed with the raw
  `gsap.d.ts` diagnostics, every fixture test still green), green again
  after removing it; a per-file error (injected into `marquee`) still
  fails only that fixture's test plus the file-level gate.

### Installed components (blind review round 3, fourth pass)
- The CLI component `tsc` gate spawned `tsc` with a generated tsconfig that
  had no `paths` redirect for `react`, so the subprocess always resolved the
  root's React 19 `@types`, even under `npm run test:react18`: the
  `--import` loader hook only redirects the parent process's own runtime
  imports, never a subprocess it spawns. No `tsc` run anywhere checked the
  installed fixtures against React 18 types, so reverting `GsapScrub`'s
  `useRef<T | null>(null)` fix stayed green everywhere. `react18-register.mjs`
  now sets `SV_REACT18_DIR` (its value read straight from
  `react18-paths.mjs`, the same module `react18-tsc.mjs` already used for
  `src/`), and the gate adds the same `paths` redirect and canary when that
  variable is set. Proved red by reverting the `GsapScrub` fix under
  `npm run test:react18` (the gate failed with "Cannot assign to 'current'
  because it is a read-only property"), green again after restoring it.
- With the gate actually checking React 18 types, four more fixtures failed
  it: `HeroCinematic`, `PointerTiltGrid`, `StickySteps` and `ThreeScene` all
  pass a hook's `RefObject<T | null>` (the same shape `GsapScrub` needed to
  satisfy both majors) straight into a host element's `ref`. React 18's
  types compare that generic argument literally against `RefObject<T>`
  instead of expanding both to `{ current: T | null }`, so `T | null` fails
  where `T` succeeds even though the two are structurally identical. Each
  now casts the ref to `React.RefObject<T>` at the JSX call site, the same
  shape `GsapScrub` already needed for its own mutable `useRef`.

### Installed components (blind review round 4)
- `stats-countup` declares `core.css` as well as `state.css`. The acts clock
  is `calc(var(--sv-live) * var(--sv-acts-count))` and `--sv-live` is
  declared in `core.css` alone, so a consumer who imported exactly what the
  registry asked for got `--sv-act: 0` the moment the driver booted, and
  every number rendered as 0. Without JS the numbers were correct, which is
  why nothing caught it: the gallery page loads the whole `styles.css`.
- `StatsCountup`'s markup is valid again: the screen-reader value and the
  `aria-hidden` counter both sit inside the `<dd>` (`.count`). The readable
  value used to be a `<span>` sibling of the `<dd>` inside `<dl><div>`, which
  is outside the definition-list content model, and the only `<dd>` was
  `aria-hidden`, so every term reached assistive tech with no definition.
- Every installed component renders its constant CSS with
  `dangerouslySetInnerHTML` instead of a `<style>` text child. React 18's
  `renderToStaticMarkup` escapes `>` inside `<style>` (React 19 does not),
  and `<style>` is a raw-text element, so the entity never decodes and every
  child-combinator rule was dropped: under React 18 SSR, `sticky-steps` lost
  the whole `--st-d` rule and its shots stopped crossfading. CSP is unchanged
  from any other inline `<style>`: a `style-src` nonce or hash.
- `test/cli-components.test.mjs` compares the React 18 render with the React
  19 one instead of returning early: that skip is what let the corrupted
  React 18 markup ship unseen.
- New unit gate: an effect must declare the stylesheets the presets it uses
  read variables from, so a rule reading `var(--x)` with no fallback can
  never again be one undeclared import away from computing to nothing.
- Second pass (verifier findings on 91e2b9c): the gallery's CSS tab for
  `stats-countup` still generated the number on the `<dd>` itself
  (`.stats .stat::after`), while its React tab had moved `data-suffix` onto a
  `.count` span inside that `<dd>`. Copy both tabs, which is what the page
  invites, and `::after` resolved `counter(n) ""`: the number rendered on the
  `<dd>` without its suffix, on top of the readable value in the span, so the
  block announced "248+" and then "248". The double announcement this fix
  removed, reintroduced in the documented snippet. The CSS tab, the Tailwind
  tab and the React tab now all carry the `.count` span, the rule is
  `.stats .stat .count::after`, and no `.stat::after` is left.
  `timeline-scrub` shipped the same split (`.tl-year::after` against a React
  tab with a `.tl-count` span) and is fixed the same way.
- The requires closure gate iterated `requires.styles` itself, so it could
  only ever check stylesheets an effect had already declared: a component
  using a preset from a stylesheet named nowhere had no rule to read and
  stayed green, and `sticky-steps` on `styles: ['core']`, with `.sv-stage`
  and the `pin.css` that owns it dropped, passed. Class and variable
  ownership is now mapped over all six stylesheets, and an effect must
  declare whichever one owns each class it renders, whichever one those
  rules read their variables from, and whichever one declares a variable its
  own embedded CSS reads without a fallback.

### Testing (blind review round 4)
- New harness step, the isolated installation gate
  (`demo/bench/harness/installed-gate.mjs`, `render-installed.mjs`, wired
  into `npm run test:e2e`): per effect in `demo/fx/registry.json` it installs
  the component into a temp dir with the real CLI, renders it with its
  `previewProps` under React 18 and React 19, and loads it into headless
  Chrome with ONLY the stylesheets `requires.styles` names, plus the engine.
  It asserts the page renders complete with no engine at all, that the
  effect's key behavior happens with the engine (the counters resolve above
  zero, the pin clock scrubs the year counter, the shots crossfade, the hero
  is split and live), that reduced motion hides nothing and adds no inert
  content, that a `<dl>`'s content model and terms hold, and it counts the
  focusable elements and fails if any is unreachable: the four Sections ship
  none with their preview props, so that last one is a guard, not a claim.
  Proved red on the three defects above before they were fixed.
- `npm run test:e2e` installs the isolated React 18 first
  (`scripts/react18-install.mjs`, the same one `test:react18` uses): the gate
  renders under both majors and fails loudly rather than silently halving
  its coverage.
- New unit gate on the gallery tabs of a Section: every class the React tab
  renders is shown or styled in the CSS tab, and every `content:`
  pseudo-element the CSS tab generates hangs off a class the React tab
  renders. The two tabs are one block spelled twice, and a reader pastes
  both. Proved red on both pane splits above.
- Second pass (verifier finding on fe4d844): that gallery gate compared two
  flat SETS of class names, so either half of the pane split it repaired
  stayed green on its own. The rule back on `.stats .stat::after` with the
  `.count` span still in the CSS-tab markup announces "248+" then "248"
  (`content` computing `counter(n) ""`), the original defect verbatim; the
  mirror, the rule on `.count` with the span dropped from the markup, ships a
  snippet that renders no number at all. Every `content:` rule in the CSS pane
  whose value uses `counter()` is now checked against the element it prints
  on: the class must appear in that pane's own markup, and it must be the
  class the React pane hides from assistive tech when the React pane hides
  one. Proved red on both mutations. `cssPaneClasses()` strips comments as
  `classesIn()` already did, so a class named only in prose no longer counts
  as documented.
- `installed-gate.mjs` closes its HTTP server and every page it opened in a
  `finally`: a throw in the render path no longer leaves a listening socket
  and a pile of tabs behind for the rest of the run.

### Tooling
- `npm run demo:sync` is idempotent again: the bench page's inlined engine
  marker was lazy on the content but only matched a fixed 3-newline gap
  before `</script>`, and the replacement kept the fresh IIFE's own
  trailing newline on top of that gap, so every run against a live driver
  added one more blank line and `demo/bench/scrollvars.html` never
  settled. The marker now consumes however many blank lines already
  accumulated instead of a fixed count, so it self-heals instead of
  drifting. `.github/workflows/ci.yml` and `.github/workflows/release.yml`
  now include `demo/bench/scrollvars.html` in the generated-files gate.

### Slider
Blind review round 3 (GPT-6 Astra), findings 8, 9 and 10, verified in real
Chrome with a puppeteer-core probe (5 slides of 100px, container 300px
wide, scrollWidth 500).
- `slideStart()` walked the offsetParent chain and always subtracted the
  container's own border (`clientLeft`/`clientTop`), even when the
  container itself was the slide's offsetParent. offsetLeft is already
  measured against the offsetParent's padding edge in that case, so the
  border was subtracted twice: a bordered, positioned container gave -10
  for its first slide instead of 0. The walk now stops the moment it
  reaches the container and only falls back to the absolute-position
  subtraction (plus the border) when the container is skipped entirely
  (a statically positioned rail whose real offsetParent sits further up).
- RTL mirrored the slide start against `scrollWidth` instead of
  `clientWidth`: measured in Chrome, a position:relative RTL rail gave 200
  for its first slide instead of 0. The mirror now uses the container's
  own client box.
- `measure()` tracked only the active index, not the active element. A
  MutationObserver-driven replacement of that element (same index, new
  node, e.g. a framework re-render) left `sv-active` on the detached old
  node and never moved it to the new one. `measure()` now remembers the
  active node as well: a changed node at the same index moves `sv-active`
  and `--sv-slide` without firing `onSlide` for an index that never
  changed.

### React
- `useTrack`, `usePointer`, `useSlider` and `useCanvasEffect` now attach
  through the ref itself instead of a mount-effect: the returned ref's
  `current` is an accessor, so React's own attach/detach (any object with
  a `current` property, unchanged between React 18 and 19) runs the
  track/untrack. A conditionally rendered target that mounts after the
  first render, or a node replaced by a new one, used to sit untracked
  until an unrelated option changed forced the effect to rerun; both are
  now tracked the moment the node attaches, and untracked on detach.
  Option changes still retrack the current node. The ref type is declared
  `React.RefObject<T>`, same as before.
- `<Scenes pin>` now takes a string that wins over `height` and the
  one-viewport-per-scene default (`pin="320vh"`), matching `<Track pin>`.
  `pin={false}` still disables the pin helper.
- `<ScrollVarsBoot nonce>`: forwarded to the pre-paint script tag, for a
  strict CSP with no `'unsafe-inline'`. Additive, no existing prop changes.
- `<ScrollVarsBoot>`'s debug overlay (`?sv-debug`) no longer mounts if the
  component unmounts before its dynamic import resolves: the effect
  cleanup now sets a `disposed` flag the import's callback checks first.
- `useSlider`'s `handleRef` kept pointing at a destroyed `SliderHandle`
  after the tracked node detached (a conditional unmount, a node swap):
  `next()`, `prev()`, `goTo()` and `handle.current` on a detached slider
  drove a dead container, including starting a new glide `requestAnimationFrame`
  loop nothing could stop. The `useAttachedRef` cleanup now also sets
  `handleRef.current = null` before destroying the handle. Checked every
  other hook built on `useAttachedRef` for the same shape (`useCanvasEffect`,
  `usePointer`): neither keeps a handle ref beside it, so only `useSlider`
  needed the fix.
- `<Modal open>` renders the `open` attribute: a modal that starts open is
  now open in the server markup and stays open without JS and before
  hydration (README, "open ones open"), instead of shipping a closed dialog.
  On mount the effect removes that attribute and calls `showModal()`: a
  dialog opened by the attribute is NOT modal, `showModal()` throws on an
  open NON-modal one, and the old `!dialog.open` guard skipped it and left
  it non-modal. It removes the attribute rather than calling `close()`, which
  fires a close event that a controlled parent answers by closing the modal
  it just rendered open. That promotion runs exactly once per open: a
  repeated `showModal()` on a dialog that is already modal returns early by
  spec, but dropping the attribute first walks past that early return, and
  the second call records a node inside the dialog as the element to restore
  focus to, so closing dropped focus on the body instead of the control that
  opened the modal. Visible under StrictMode, which double-invokes effects
  in development and is the default in Next.js and in the Vite and CRA
  templates. The rendered attribute is frozen at the first
  render, since from mount on the effect owns it and React writing it would
  strip `open` off a modal dialog without taking it out of the top layer.
  The no-`<dialog>` fallback path is unchanged, the attribute still tracks
  state in both directions there. No new prop, no API change.

### CI
- CI now proves the React layer on React 18, not only the React 19 the
  root installs: a new `test-react-18` job (`npm run test:react18`, also
  runnable locally) installs react@18, react-dom@18 and their `@types`
  into `node_modules/.cache/react18` (its own package.json, `--no-save`,
  never the root `package-lock.json`), type-checks `src/` against those
  `@types` instead of the root's through a generated tsconfig `paths`
  entry (guarded by a canary that fails loudly if the redirect ever
  silently falls back to React 19), and runs `test/react.test.mjs` and
  `test/cli-components.test.mjs` with a `node --import` loader hook that
  redirects every `react`/`react-dom` import to that install for the
  process (`NODE_PATH` does not affect ESM resolution).
- `react: Marquee duplicate is aria-hidden and inert` now asserts the
  literal wire format `inert=""`, not just the attribute's presence: the
  case both React majors must agree on (`{ inert: '' }` under 18,
  `{ inert: true }` under 19).

### React types
- Proved, with a new fixture, that the natural consumer idiom
  (`const ref = usePointer<HTMLDivElement>(); return <div ref={ref} />`,
  no cast) type-checks under both React 18 and 19 for `usePointer`,
  `useTrack`, `useScenes`, `useCanvasEffect` and `useSlider`: each hook's
  return type has been `React.RefObject<T>` since ADU-106, which already
  satisfies a JSX ref under both majors' types. `test/cli-components.test.mjs`
  now compiles a `HookRefIdioms.tsx` fixture alongside the installed CLI
  components under the same tsc gate (both `npm test` and
  `npm run test:react18`), and fails loudly if a future signature change
  regresses back to a nullable `RefObject<T | null>`. The `as
  React.RefObject<T>` casts ADU-108 added around `usePointer`/`useTrack` in
  `HeroCinematic`, `StickySteps`, `ThreeScene` and `PointerTiltGrid` are
  redundant now, left in place to avoid touching lines the fx gallery work
  in flight also edits. No type or runtime change: `dist/react/index.js` is
  byte-identical before and after.

### Gallery
- The gallery preview for the four Sections (`hero-cinematic`, `timeline-scrub`,
  `sticky-steps`, `stats-countup`) is no longer a hand-typed HTML string: it is
  the installed component itself, compiled with esbuild and rendered with
  `react-dom/server` (the same pipeline `test/cli-components.test.mjs` already
  proves every fixture against), with a small `previewProps` object per
  Section (`scripts/fx-data.mjs`) standing in for real content. Preview and
  component now share one source, so they cannot drift. A component that
  attaches via a client hook (`usePointer`, `useScenes`, a bare `<Track pin>`)
  has no scannable `data-sv` attribute in its server markup, so its gallery
  page keeps a tiny `previewScript` (documented on the effect entry) that
  calls the vanilla driver directly once `sv.js` loads.
- `test/cli-components.test.mjs`'s class-token parity check (installed
  component vs. hand-written preview) is replaced, for these four, by an
  assertion that `demo/fx/<slug>.html` literally contains the component's own
  render; the check for every effect that still has a hand-written preview
  is unchanged. Rendering with `previewProps` also fails the test on any
  React warning to stderr.
- `demo/bench/harness/e2e-invariants.mjs`'s pin-stage occlusion sweep no
  longer flags the visually-hidden sr-only text used alongside an
  aria-hidden visual counter (`Split`, `TimelineScrub`'s year,
  `StatsCountup`'s count: `clip-path: inset(50%)`, by design the same text
  and position as the digit it describes): nothing on screen for it to
  cover or be covered by. The rendered `TimelineScrub` preview is the first
  page that put this pattern inside a `.sv-stage`, where the sweep actually
  looks.
- Second pass (verifier finding on 8ecd8f1): that sr-only exclusion tested
  `clip-path !== 'none'` alone, which also excludes a normal-sized element
  that only wears a decorative `clip-path` mask (a circular reveal effect,
  for instance), so visible text covered by a panel there would silently
  drop out of the sweep. Real sr-only text is pinpoint-sized (1px by 1px,
  matching `SR_ONLY_CSS` in `src/core/split.ts` and `SR_ONLY` in
  `src/react/index.tsx`) in addition to being `clip-path`'d, so the
  predicate now requires both. A new negative fixture
  (`demo/bench/harness/fixtures/pin-stage-clip-path-occlusion.html`) proves
  a masked, normal-sized element under an opaque panel is still examined
  and reported.
- Third pass (verifier finding on 4f637e0): the pinpoint-size half of that
  same predicate read `el.getBoundingClientRect()`, which measures the
  painted rect. A real sr-only span nested under a `transform: scale(2)`
  ancestor (`sv-tilt` and `sv-deck` both transform their content) paints
  at 2px by 2px, so it read as "not pinpoint", escaped the exclusion, and
  became a false occlusion candidate. The predicate now reads
  `offsetWidth`/`offsetHeight` instead, the layout box, which an ancestor
  transform never changes. The same fixture gained a positive case: a
  genuine sr-only span under a scaled ancestor, covered by nothing, that
  must be excluded outright (not examined, no false occlusion) while the
  existing masked, normal-sized element is still reported.
- Fourth pass (panel finding): the "paste the preset" CSS snippets for
  `staggered-reveal` and `split-reveal` keyed the entrance rule on the
  `.sv-live` class, while `styles/core.css` keys it on `var(--sv-live)`.
  A consumer who pasted the snippet instead of loading `core.css` missed
  the settle-visible behavior a `once` entry gets from `releaseEntry()`
  (an inline `--sv-live: 1` with the class already removed), so the
  content silently dropped back to hidden the moment the tracker settled.
  Both snippets now copy `core.css`'s own rule shape, the `--sv-live`
  variable declarations included.

### Docs
Blind review round 3 (GPT-6 Astra), section 2: wording that had drifted
from the code it describes.
- README's size intro: the "min+gzip" label now reads "JS min+gzip, CSS
  gzip as shipped", and the "typical page" number is stamped from
  `docs-data`'s `typical` size instead of hand-typed; the sizes table intro
  reads "per module entry", not "per import"; the VARS table's velocity
  column reads "viewport-heights/s", not "vh/s".
- `sv-auto`'s doc row says children beyond 10 share order 10, not "the
  rest". The pin helper doc says it sets `position: relative` only when
  the wrapper is static, keeping authored positioning otherwise. The
  sequenced-scrub example uses `data-sv-pin="320vh"` and a `sv-stage`
  child, not the empty attribute and `.outer`/`.sticky`.
- The slider's two return shapes are documented separately: `slider()`
  returns the handle itself (`next/prev/goTo/seek/active/state/destroy`);
  `useSlider()` returns `{ ref, active, next, prev, goTo, handle }`, where
  `handle` is a ref to that same handle.
- The no-JS "complete static page" claim lists its two exceptions by
  design (class-toggled panels stay closed, native `sv-view-*` still
  runs), and that a click-driven `sv-acts` target needs `toggles()` to
  start at zero. Nested scrollers documents `--sv-stage-height` and that a
  bordered root is measured from its client box. `Split`'s char mode
  documents its `Intl.Segmenter` requirement on both server and client
  render (Firefox 125+), or emoji and combining marks can split
  differently across hydration.
- `<Scenes>`'s render function is documented as server-rendered inside a
  client component boundary, not "client-side"; and a string `pin` wins
  over `height`. `<ScrollVarsBoot>`'s pre-paint hiding is documented as
  gated on `IntersectionObserver` and `ResizeObserver`. The compat floor
  explicitly covers `sv-deck` and no longer depends on
  `Array.prototype.flatMap`. AGENTS.md's build line now names
  `scripts/build-styles.mjs`.
- `scripts/docs-build.mjs`'s CHANGELOG-to-HTML renderer (`mdLite`) fixed
  two bugs: a `### ` sub-heading rendered as literal text instead of a
  heading, and a multi-line bullet only wrapped its first line in `<li>`,
  dropping every continuation line from the rendered list. Rewritten line
  by line instead of by regex backtracking: `### ` now renders as `<h4>`,
  and a bullet's indented continuation lines join into the same `<li>`.
- Second pass (verifier finding on 31ee8af): `scripts/docs-build.mjs`'s
  generated browser-support table hardcoded Firefox at 74+ (Mar 2020),
  contradicting README's and AGENTS's 78+ (Jun 2020, `:is()`/`:where()`),
  the version core.css actually needs. Fixed to 78+ (Jun 2020). The same
  sweep found the table's per-part CSS sizes (core, pin, slider, tilt,
  state, ui) hand-typed and stale against what `docs-stamp.mjs` measures
  and stamps into README (core read 1.9 KB against a measured 2.2, pin 1.8
  against 2.5, state 1.5 against 2.1, ui 0.8 against 0.7; slider and tilt
  happened to still match). `scripts/docs-build.mjs` now imports
  `measureSizes` from `docs-data.mjs`, the same source `docs-stamp.mjs`
  reads, so the generated docs page and README render the same measured
  numbers instead of two hand-typed copies that can drift apart.

### React (blind review round 4)
- `<Modal>` without native `<dialog>` support now closes. The effect
  branches once on `showModal`: where the element is unknown there is no
  `open` PROPERTY, so the old `!open && dialog.open` guard could only ever
  open it. The fallback drives the attribute in both directions with
  `setAttribute`/`removeAttribute`, never `toggleAttribute`: the engines
  that reach that branch are the ones without `<dialog>` (Safari below
  15.4, Firefox below 98), and Safari 11 and Firefox 60 to 62 are inside
  the supported floor while predating `toggleAttribute`, where the effect
  would throw and React would unmount the tree.
- `useScenes()` clamps its scene to `count - 1` when the count changes. The
  driver emits nothing at all for `scenes <= 1`, so a count going from N to
  1 used to keep reporting the last index forever, and a consumer marked
  its only shot inert.

### Slider (blind review round 4)
- The wheel assist now reads the container's COMPUTED `scroll-snap-type` at
  init, not only the inline style: an instance set to `none` by a
  stylesheet or a utility class (Tailwind's `snap-none`) is left alone,
  same as an inline one, instead of being snap-suspended and scripted to
  the nearest slide.
- `state().progress` and `--sv-progress` are clamped to 0..1. Elastic
  overscroll drove them past both ends, and a follower chained through
  `onScroll` + `seek(progress)` was seeked outside its own range.

### Click driver (blind review round 4)
- `toggles()` writes `--sv-state` at boot from the target's class, so
  markup that ships open no longer disagrees with its own class until the
  first click.
- Triggers are grouped by the PAIR (resolved target element, toggled
  class) instead of by the `data-sv-target` string, so two triggers naming
  the same panel through different selectors keep each other's
  `aria-expanded` in sync, while two controls toggling different classes
  on that same panel (a hamburger on `open`, a second control on `pinned`)
  keep separate states. Grouping by the element alone made one click
  report `aria-expanded="true"` for both, the second one with its class
  absent.

### Presets and no-JS (blind review round 4)
- `.sv-counter`'s `counter-reset` and `::after` are keyed on
  `:is(.sv, [data-sv])`, like the no-JS guard that feeds them `--sv-int`:
  attribute-only markup used to get the variable but render an empty
  element without JS. The no-JS e2e sweep asserts the digits, not just the
  variable.
- `dialog.sv-pop:not([open])`'s fade is wrapped in
  `@supports selector(dialog:modal)`: the type selector also matches the
  unknown element an engine without `<dialog>` parses, which hid the
  documented static fallback panel with no UA `display: none` behind it.

### Docs (blind review round 4)
Blind review round 4 (Astra on 7489a11), section 2: eight docs mismatches
against the code ADU-129 to ADU-132 shipped.
- The docs page's "Preset vocabulary" table now notes that a scroll-driven `sv-acts`
  clock needs `core.css` as well as `state.css`, matching the import
  comments ADU-129 already fixed in README and AGENTS.
- The no-JS exception list now includes the marquee: `ui.css` animates it
  via a plain `@keyframes` rule that never depends on the driver.
- The compat floor sentence separates what the fallback animates (reveal
  presets `sv-rise`/`sv-fade`/`sv-slide-l`/`sv-slide-r`/`sv-auto`/`sv-drift`
  and pin presets `sv-curtain-l`/`sv-curtain-r`/`sv-rail`) from what stays
  static below the floor, with the real cause per preset: `sv-split-rise`
  has no fallback rule and its animating selector is an `:is()` those
  parsers drop whole, `sv-spread` has no fallback rule either but its
  selector parses fine and simply has no `translate`/`rotate` to apply.
  `src/compat`'s header comment named only curtain, rail and drift as the
  covered presets: it lists the same nine now, plus `sv-deck`,
  `sv-split-rise` and `sv-spread` with a reason each.
- `sv-deck`'s fallback is documented as ADU-131 shipped it: a static,
  non-overlapping unstack, no `max()` involved.
- Modal without `<dialog>` support is documented as an open static panel:
  `styles/state.css` scopes the closed-dialog fade to
  `@supports selector(dialog:modal)` and keeps the type selector out of the
  `display: none` rule, so nothing there hides the element in either state.
  The `open` attribute still tracks state in both directions (ADU-132), so
  the consumer's own CSS can hide it.
- AGENTS.md's pinned-skeleton line now states an inline static wrapper
  gets `position: relative` (ADU-130), matching README.
- The knobs sentence notes the one exception to zero-specificity defaults:
  `--sv-order`'s automatic-stagger value is declared on the child itself,
  by `.sv-auto > :nth-child(n)` and `.sv-stagger > :nth-child(n)`, and an
  inherited `:root` value never applies where the child declares its own.
  Those rules are (0,2,0), so an override needs an inline value or a rule
  at least as specific.
- The "SSR, SEO and the Lighthouse load profile stay untouched" claim is
  scoped to the no-JS path: a JS-enabled Lighthouse run sees the pre-paint
  script hide entrances before paint and the pin helper write heights on
  attach.

### Driver (blind review round 5)
- The driver owns the live state of every element it tracks. It writes the
  flag twice now, as the `sv-live` class and as an inline `--sv-live`, and
  re-asserts both on any measured frame where the DOM disagrees. A React
  `<Track>` renders `className={'sv ' + className}`, so a prop change
  rewrites the whole class attribute and takes the driver-added `sv-live`
  with it: the section faded back out (`.sv` alone declares `--sv-live: 0`),
  and a settled `once` section, with no tracker left to put the class back,
  stayed at opacity 0 for good. The inline flag survives the rewrite; the
  class returns on the next frame the driver measures.
- `track()` on an element that still carries a settled `once` `sv-live`
  clears the class and the inline flag before the first frame, so the new
  entry (which starts not live) and the DOM agree and the entrance replays
  when the element enters the band again.
- Precedence, on tracked elements only: `--sv-live` is now an INLINE
  declaration, and inline outranks every non-important author rule. A rule
  of your own that lifts the flag (`#hero.sv { --sv-live: 1 }`) loses to
  the driver from the first frame its live state changes (`writeLive()`
  runs on a live-state transition or a class disagreement, so an element
  that never enters the band never gets the inline flag), and the "remove
  `sv-live`, add it back next frame and the entrance replays" trick now
  works only on
  elements the driver does not track (a hand-flipped `.sv`, a
  `toggles()`-driven widget). On a tracked element the driver owns the
  flag: re-tracking replays the entrance instead.
- The identity guard runs after `onTravel` and after `onPin` too, not only
  after `onLive`: a callback that untracks its own element no longer gets
  `--sv-pin`/`--sv-scene` written inline (variables the release had already
  cleaned up, so they stayed forever) plus one extra `onScene`.
- The `prefers-reduced-motion` listener falls back to the deprecated
  `addListener` when `MediaQueryList.addEventListener` is missing (Safari
  below 14, inside the supported floor), where the optional call made the
  whole preference a no-op.

### Presets and no-JS (blind review round 5)
- **Added: the `data-sv-off` attribute** (public API, driver-managed).
  Releasing a tracked element now settles it to its no-JS RENDERING, not
  just to a visible entrance. ADU-130's inline `--sv-live: 1` covered the
  entrance presets, but `.sv` stays and `html.sv-on` never comes off, so
  after `stopScan()`, a `ScrollVarsBoot` unmount or an option change the
  pin presets kept reading a clock that had stopped: `.sv-curtain-l`/`-r`
  sat closed over the content, `.sv-deck` stayed stacked in one grid cell,
  `.sv-range` children stayed at `--sv-r: 0` (opacity 0), a `.sv-spread`
  scrubbed from `--sv-t` stayed fanned into an overlapping stack and
  `.sv-stage` kept `position: sticky`, `100vh` and `overflow: hidden`.
  `releaseEntry()` marks the element `data-sv-off`, and every
  `html:not(.sv-on)` guard in `styles/pin.css` and `styles/core.css` now
  has a `[data-sv-off]` twin, so both class markup and `[data-sv]` markup
  settle static. `track()` takes the marker back off. The guard reaches the
  DESCENDANTS these presets style, which an inline variable on the tracked
  element cannot.
- An attribute, not a class: React's `<Track>` renders
  `className={'sv ' + className}`, so a prop change rewrites the whole
  class attribute, and a released element has no tracker left to put a
  dropped class back. A class marker would have snapped the stage back to
  `position: sticky` with the curtains over the content, permanently.
- Each `[data-sv-off]` twin is its own rule, never a selector appended to
  the `:is(.sv, [data-sv])` guards next to it: a parser that predates
  `:is()` throws away the whole selector list, and Firefox 72 to 77 is
  inside the supported floor and not covered by the `@supports` block
  below.
- Second pass (verifier findings in Chrome on ce777d0): the marker is read
  as `[data-sv-off] X`, which matches through ANY depth, so a released
  ANCESTOR settled every preset under a descendant whose clock was still
  running. `track(outer)`, `track(inner, { pin: true })`, `untrack(outer)`
  left the inner tracker writing `--sv-pin` into a stage flipped back to
  `position: static`, curtains at `display: none` and a deck unstacked.
  Nested trackers are a first-class pattern here (the nearest tracker, not
  any live ancestor, owns spread), and `:has()` is far above the supported
  floor, so the driver keeps the marker honest instead: `releaseEntry()`
  marks an element only once nothing tracked is left inside it, and each
  release settles the ancestors that were waiting on it, since `stopScan()`
  releases an outer tracker before its inner one. `track()` strips the
  marker off the whole ancestor chain, not only off its own element, so a
  section re-mounting under a released one does not run its clock against
  presets already settled static.
- The `.sv-spread` twin fires when the tracked element IS the spread
  container too. Its no-JS guard (`html:not(.sv-on) .sv-spread > *`)
  requires no tracker ancestor, while the twin was a descendant combinator
  and needed a separate marked ancestor: with the documented scrub idiom on
  the container itself (`<div class="sv sv-spread" data-sv data-sv-travel>`)
  a released spread stayed at `translate: calc(100% + 16px)`,
  `rotate: 5deg`, where no JS gives `none`. Both guards with no ancestor
  requirement now carry the second marker position too
  (`.sv-spread[data-sv-off] > *`, `.sv-stage[data-sv-off]`), as plain comma
  lists rather than `:is()`. The audit compares each twin's ANCESTOR SHAPE
  with its guard's instead of stripping a selector prefix, and reads every
  file in `styles/`, not the two that carry guards today.
- `styles/pin.css` carries an `@supports not (translate: 0)` block: with
  JavaScript on and no `compat()` call, an engine without individual
  transform properties (Chrome below 104, Firefox below 72, Safari below
  14.1) runs the driver, so `html.sv-on` is set and the no-JS guards cannot
  fire, while the stacking half of these presets is plain layout and
  survives. The deck unstacks and the curtains open there too. The curtains
  open with `transform` rather than the no-JS `display: none` on purpose:
  `scrollvars/compat`'s fallback sheet re-expresses the same panels with
  that property and is appended later, so it still outranks this block and
  animates them.
- Third pass (verifier and panel findings in Chrome on f5a81eb): the marker
  had two more holes, both in the bookkeeping around the waiting set. A
  `once` entrance descendant leaves the entry map inside `apply()`, not
  through `releaseEntry()`, and that second exit never swept the elements
  waiting on it: an ancestor released while such a descendant was still
  tracked stayed unmarked for good, its `.sv-stage` sticky and clipping with
  the curtains over the content, until an unrelated later release happened to
  sweep the backlog. The sweep is its own function now and both exits call
  it, without marking the settled element itself, which stays live. And
  `track()` stripping the marker off the ancestor chain FORGOT those
  ancestors: released, unmarked, and never marked again. An ordinary
  `<Track>` prop change under a released shell reaches it, and so does
  `stopScan()` followed by one section re-mounting, which is what the
  stripping exists for. A cleared ancestor that carried the marker, or was
  still waiting for it, goes back into the waiting set, so the next release
  that empties it marks it again. The sweep returns immediately while
  nothing is waiting, so an ordinary release pays nothing for either fix.
- `markReleased()` writes the attribute with the same optional call
  `clearReleased()` removes it with (`setAttribute?.`), so both halves of the
  pair hold on the same elements.
- Size, measured, because these are published numbers: the release
  bookkeeping takes the core entry (`scrollvars`, min+gzip) from 6.0 KB to
  6.2 KB, and the stamped bundle comparison with it, from `~8× less bundle`
  than gsap + ScrollTrigger to `~7×` (the ratio is arithmetic on the stamped
  KB, 46.3 / 6.2). The released twins take `styles/pin.css` from 2.6 to
  2.9 KB gzip and `styles/core.css` from 2.3 to 2.4 KB, `styles.css` from
  8.2 to 8.6 KB and the headline typical page from ~4.7 to ~5.0 KB, with
  their comments trimmed to one note per guard family (`styles/state.css`
  is back at its 2.2 KB: the note that had moved it is in this changelog,
  which costs no bytes on the wire). `styles/core.css` crosses its rounding
  boundary on the RULES alone: 2392 bytes at the base, 2411 with the new
  selectors and no comment at all, against 2406 for 2.35 KB.
- Fourth pass (the ADU-145 docs verification, round 5): the `@supports not
  (translate: 0)` block above unstacked the deck and opened the curtains,
  but left `.sv-stage` at `position: sticky`, `height: 100vh`,
  `overflow: hidden`, whose only escapes were the no-JS, released and
  reduced-motion guards, never this block. Measured in Chrome with the
  block's declarations applied by hand: four 45vh cards in an 800px stage
  landed at 0..360, 360..720, 720..1080, 1080..1440, so the fourth card sat
  entirely outside the clip box and the third was half gone. The block now
  resets the stage the same way its reduced-motion twin already does, and
  its own comment states exactly what it guarantees: the curtains sit
  parted and static, nothing overlaps, content stays in flow. Narrowed in
  round 7 (ADU-168): with `compat()` installed the release stands down, so
  the stage stays pinned below the floor and stage content taller than the
  stage clips again there, and not calling `compat()` is the escape.
- Size, measured: the stage reset takes `styles/pin.css` from 2.9 to 3.0 KB
  gzip (2995 to 3030 bytes; the new selector alone costs one byte against
  the file's existing repetition, the comment the rest) and `styles.css`
  from 8.6 to 8.7 KB (8845 to 8880 bytes).

### Gallery (blind review round 5)
Blind review round 5 (Astra on 7992458), findings 7c, 10, 11.
- `StickySteps`'s reduced-motion block now resets `.st-steps > li` and
  `.st-dots i` too (opacity 1, no translate, no scale), not only `.st-shot`:
  the pinned stage unpins under reduced motion by design, so `--sv-scene`
  keeps advancing, and every step not at the current scene stayed at 30%
  opacity and slid with the raw scroll forever. The installed gate's
  reduced-motion pass asserts every step and dot resets, for `sticky-steps`.
- `RotatingWords` guards an empty word list (no interval scheduled, so
  `(i + 1) % 0` never runs and `--sv-word` never goes `NaN`) and clamps the
  index on the render that sees a shrunk list, same shape as `useScenes`.
  Previously a late word list (fetched after mount) could tick once against
  an empty array, poison the index to `NaN`, and never recover once real
  words arrived; a shrinking list stranded the index past the end.
- The staggered-reveal and split-reveal "paste the preset" snippets carry a
  fallback on every `var(--sv-*)` they read (`--sv-ease`, split-reveal's
  `--sv-duration` and `--sv-stagger` too): without `core.css` the bare vars
  made the `transition` shorthand invalid and the entrance snapped instead
  of animating. split-reveal's snippet also regained
  `.sv-split > span[aria-hidden] { display: inline-block }`
  (`styles/core.css`'s own rule): without it `translate` does nothing on
  the non-replaced inline spans. The GSAP React snippet's `useRef` is typed
  `gsap.core.Timeline | null`, matching the installed `GsapScrub` twin,
  instead of a type that never allows the `null` the ref is assigned.

### Scanner (blind review round 5)
- `scan()`'s `MutationObserver` callback no longer untracks a node that is
  still connected. A DOM "replace all" (`parent.replaceChildren(...)`,
  `replaceWith`) queues one mutation record with a retained node in BOTH
  `addedNodes` and `removedNodes`, and a list reorder splits the same move
  across a removal record and an insertion record in one batch: either way
  the node was never really removed by the time the observer fires. The
  remove path now bails with `if (el.isConnected) return`, so a retained or
  reordered node keeps its live entry instead of being untracked and
  re-tracked, which used to strip its state and hide it for a frame.

### Scanner (blind review round 5, second pass)
- The remove path's connectedness check is `scope.contains(el)`, not
  `el.isConnected`. A scoped `scan(root)` only observes `root`'s own
  subtree: a tracked node moved OUT of `root` into another still-connected
  part of the document leaked forever, since it stayed `isConnected` and no
  further mutation record for it ever arrives. `scope.contains(el)`
  degrades to the same check as `isConnected` when `scope` is the document
  (the churn fix above still holds), is correct for a scoped root, and also
  fixes `scan()` on a genuinely detached root, where `isConnected` is
  always false and could never trigger the churn guard at all.

### Pointer (blind review round 5)
- `trackPointer()` only writes `--mx`/`--my` on a descendant of its own
  container. `event.target.closest(selector)` used to walk straight past
  the container, so a `.sv-tilt` ANCESTOR of the tracked container matched
  and received the pointer output. The match is now required to be inside
  the container (`container.contains(match) && match !== container`).
- Teardown now clears `--mx`, `--my` and the `sv-pointer-leave` class from
  the last hovered element. It used to only remove the listeners and cancel
  the pending frame, leaving a destroyed instance's card frozen mid-tilt.

### React (blind review round 5)
- `<Slider>` composes a consumer `onPointerEnter` / `onPointerLeave` with
  autoplay's hover pause instead of letting the props spread replace it.
  Both are public props (the component extends `HTMLAttributes`), so a
  consumer `onPointerEnter` used to silence the pause entirely, and a lone
  consumer `onPointerLeave` left the slider hovering forever, autoplay
  never resuming after the first hover.
- `<Slider>`'s responsive `perView` stylesheet is rendered as raw text
  (`dangerouslySetInnerHTML`) instead of a `<style>` child. react-dom
  18.3.1 escapes `"` to `&quot;` inside a `<style>`, 19 does not, and
  `<style>` is raw text so the entity never decodes: on React 18 the server
  dropped every `[data-sv-uid="..."]` rule the responsive map emits, and
  hydration did not repair it. Same root as the gallery sections, which
  already render their CSS this way. The raw sink also drops React's
  `</style` escaping, which is what kept an interpolated value inert, so
  `perViewCss` coerces every part it interpolates with `Number()`: a
  `perView` off untyped data (a CMS) renders `--sv-per-view:NaN`, a
  declaration the CSS parser drops, and can neither close the element nor
  emit a tag. No CSP change, the sheet is still one inline `<style>`.

### Testing (blind review round 5)
- `test/react.test.mjs`'s `flushFrames` rethrows what a frame scheduled by
  the running test throws, and keeps swallowing only frames left pending by
  earlier tests (queued callbacks carry the test that scheduled them). A
  driver or canvas frame that blew up could not fail a React test before.
  A frame scheduled from inside a running frame inherits that frame's test,
  not the flushing one, so a leftover canvas loop rescheduling itself does
  not blow up whichever later test happens to flush it twice.
  Two harness tests pin both halves.

### Docs (round 5, ADU-145)
Docs read against the code merged by the five round-5 code tickets.
- `onTravel`/`onPin` are documented as exempt from near-viewport culling
  when tracked with a custom `root`, by design: the callback fires every
  frame no matter where the root sits on screen.
- The "remove `sv-live`, add it back next frame" replay trick is now
  documented as scoped to elements the driver does not track; a tracked or
  released element pins `--sv-live` inline, which outranks a non-
  `!important` author rule, so re-tracking is what replays the entrance
  there instead.
- The outputs table now says which option activates each opt-in clock
  (`--sv-t` needs `travel`, `--sv-pin` needs `pin`, `--sv-scene` needs
  `scenes` greater than 1), and the `sv-spread` scrub recipe no longer
  reads as if `--sv-t` were always written.
- The browser-support section now documents `pin.css`'s own
  `@supports not (translate: 0)` net accurately: it covers four rules (the
  stage, both curtains, the deck), not every pin preset; the stage resets
  to flow so nothing is clipped by the stage itself. `sv-rail` is the one
  exception again: the no-JS wrap guard does not apply with JS running, so
  an unwrapped track can run past the viewport edge, reachable only by a
  page-wide horizontal scroll and not at all under an
  `overflow-x: hidden` ancestor; `compat()` restores its travel, but with
  the stage released into flow that travel mostly happens off screen.
  Also noted, as a caveat pending ADU-150: a released stage can leave a
  parked curtain panel outside it, extending the document with an empty
  sideways scroll.
- The "below the floor nothing breaks" design rule holds with or without
  `compat()`: skip it and the page renders complete and static, call it
  and the page animates instead.
- `data-sv-off`, the driver-managed released twin of `html.sv-on`, is
  documented next to it in README and AGENTS: added on release, removed on
  re-track, settles every preset under it to the no-JS rendering; a
  released ancestor still holding a tracked descendant keeps waiting.
- README's compat paragraph and `src/compat/index.ts`'s header comment
  (the two hand-kept copies) stay in agreement; this pass added text next
  to them without touching that pairing.

### Pointer (blind review round 6, ADU-152, live regression)
- `trackPointer()` accepts a container that matches its own selector again.
  Round 5's ancestor fix (`container.contains(match) && match !== container`)
  closed the ancestor case it was written for but also closed the SELF
  case, which is how the gallery's flagship hero is wired
  (`trackPointer(hero, { selector: '.sv-hero' })` on the hero itself): every
  pointermove was silently dropped, `--mx`/`--my` never wrote, and the orb
  parallax was dead. `container.contains(match)` alone still rejects an
  ancestor (an ancestor is never inside its own descendant) while allowing
  the container itself (`Node.contains()` is true for the node itself), so
  the extra `match !== container` was never needed.

### Testing (round 6, ADU-152 fix pass)
- The gallery regression guard's own selector match was a false negative:
  its `\b${cls}\b` boundaries treat a hyphen as a word edge, so a selector
  reading `.hero` passed as long as ANY sibling class started with
  `hero-` (`hero-orb`, `hero-inner`), even though no element carries the
  exact class `hero`. It now splits each `class`/`className` attribute on
  whitespace and compares tokens exactly, and requires the CSS-side match
  to not be followed by a further word character or hyphen either. Proved
  red by mutating `hero-cinematic`'s React selector to `.hero`, proved
  green again on revert.
- The same guard now also scans `previewScript`, the field `hero-cinematic`
  actually renders through in the gallery (the exact path ADU-152 broke in
  production); it previously scanned only `preview`, `css`, `tailwind` and
  `react`.
- `trackPointer()` teardown while the last hovered element IS the
  self-matched container (the hero's own wiring) is now a locked-in test:
  the runtime already cleared `--mx`, `--my` and `sv-pointer-leave`
  correctly there, this closes the coverage gap.

### Slider (blind review round 6)
- The slider re-asserts the classes it owns on every measure, the way the
  driver does for its live flag: `sv-slider`, `sv-slider-y` and
  `sv-draggable` on the rail, `sv-active` on the slide nearest the centre.
  A framework that owns the rail's `className` (React re-rendering it when
  a prop like `perView` changes, with no retrack behind it) used to drop the
  first three, and a consumer restyling a slide dropped `sv-active` until
  the active index happened to change. Each is one `classList` read per
  measure, with a write only when the DOM disagrees.
- `state().position` interpolates between adjacent slide CENTRES, so the
  documented continuous position never goes backwards. It normalized the
  distance by a single slide's own size before, which made it jump back at
  every midpoint as soon as the slides had a gap: two 100px slides 16px
  apart read 0.580 and then 0.430 one pixel of scroll later. Measured old
  against new on the same fixture: gapless sliders with equal-size slides
  read exactly as before (max difference 0.0000 over 121 samples across
  the whole range). Gapless sliders with unequal slides (`--sv-span`
  making slides different widths, `--sv-gap: 0`, a real configuration)
  differ: 0.75 old against 0.6667 new at scrollLeft 0, maximum difference
  0.0833. The new value is the one that is monotone and centre to centre;
  unequal gapless slides now reading centre to centre is the intended
  contract, not a regression.
- The wheel settle (the glide 200 ms after the last wheel event) is dropped
  by whatever takes the position over inside that window: a pointerdown,
  `goTo` and everything routed through it (arrows, keyboard, autoplay), and
  `seek`. It only listened to the next wheel event and to `destroy` before,
  so a drag started right after a trackpad pan had a glide fighting it. A
  press that drops a pending settle also resumes the snap the wheel had
  suspended, since the settle it replaced is no longer there to do it.

### React (blind review round 6)
- `<Slider>`'s engine classes survive a re-render: `perView` is not an
  attach dep, so React rewrites the rail's class attribute with no retrack,
  and a consumer's own slide `className` rewrite drops `sv-active`. Fixed
  in the core slider (above), so plain `slider()` consumers whose framework
  owns the class attribute get it too.

### Scanner (blind review round 6)
- `scan()`'s `removeSplit` now bails with the same `scope.contains(el)`
  guard as `remove()`. A retained `[data-sv-split]` node (a batch
  `replaceChildren`/`replaceWith` that keeps it, or a reorder split across
  a removal record and an insertion record in one callback) was still
  restored to its original markup and dropped `sv-split`, and nothing ever
  re-split it, since the guard added for `remove()` was not carried five
  lines down. `removeSplit` was the only other early-exit path that guard
  had skipped.

### Gallery (blind review round 6)
Blind review round 6 (Astra on 3e2c18b), finding 8a. Successor of ADU-144.
- `sticky-steps`'s CSS tab now resets `.st-steps > li` to opacity 1 under
  reduced motion too, not only `.st-shot`: ADU-144 fixed this in
  the installed component but never in the tab the docs tell a reader to
  paste, so pasted code left every non-active step at 30% opacity forever
  under reduce. The reset sits in its own
  `@media (prefers-reduced-motion: reduce)` block placed AFTER
  `.st-steps > li { opacity: calc(...) }`, mirroring the installed
  component: both selectors are `.st-steps > li`, so with equal specificity
  the later rule in source order wins whichever one the media query matches,
  and a reset written into the existing media block above the base rule
  never applies. A gate (`test/cli-components.test.mjs`) compares, for
  every Section with both a CSS tab and an installed component, the
  selectors inside each side's `@media (prefers-reduced-motion: reduce)`
  block, past the installed component's own wrapper-class scoping
  (`.sv-hero`, `.sv-steps`, ...) and past chrome the installed component
  renders that the tab never documents (sticky-steps' dots), and fails on
  any drift. Audited every other effect for the same split: `hero-cinematic`
  already agreed on both sides; `timeline-scrub` and `stats-countup` carry
  no reduced-motion block on either side; `coverflow-slider`'s tab and
  installed component reset the same three properties under its own,
  differently named class (`.slide` against `.cf-slide`), a naming choice
  the Sliders category is free to make, not drift, so the gate is scoped to
  Sections the same way the CSS/React pane-pairing gate above already is.
- Second pass (verifier findings on 29669c0): that selector-text gate could
  not see either half of the defect it was written for, because it reads
  selectors and never declarations or cascade position. The e2e harness now
  judges the rendered result instead. `demo/bench/harness/installed-gate.mjs`
  renders every Section's CSS tab on its own (the tab's markup, only the
  tab's CSS, `html.sv-on` and the driver's own variables set by hand, under
  `prefers-reduced-motion: reduce`) and runs the identical probe the
  installed component passes: the reduced-motion assertion is now one
  function shared by both spellings of a section rather than two that drift.
  Proved red in Chrome on both mutations, the wrong value and the wrong
  cascade position, each of which leaves the selector-text gate green.
- Third pass (verifier finding on the same round): `splitPane` in
  `demo/bench/harness/installed-gate.mjs` sliced a gallery CSS tab into
  markup and CSS on the first blank line without checking that one was
  found. If a reformat of `scripts/fx-data.mjs` ever collapses that blank
  line, `markup` becomes nearly the whole pane, CSS text included as
  unstyled nodes, `css` becomes one character, and the rendered page
  carries no applied stylesheet at all, so the reduced-motion probe passed
  by coincidence rather than by the behavior it claims to check, exactly
  the silent failure the file's own comment promised could not happen.
  `splitPane` now throws a named error when the separator is missing.
  Proved red by collapsing the blank line in `sticky-steps`'s CSS tab and
  green again once restored; a unit test (`test/installed-gate.test.mjs`)
  covers both the throw and the ordinary split.

### Canvas (blind review round 6)
- An engine whose CSSOM has no `aspect-ratio` at all (below the README's
  Safari 12.1 canvas gate) no longer has its canvas marked `pinned`. The
  previous pass stopped the throw there but kept the premise: the ratio
  write was dropped by the engine, yet `pinned` still switched the
  backing-store write to rounding both axes independently, which is only
  safe once the CSS engine owns the height. It was instead still derived
  from the intrinsic attribute ratio the harness itself rewrites every
  pass, so the runaway ADU-107 fixed came back below the floor: a 30x61
  canvas at DPR 0.51 walked 62, 64, 66, 68, 70 CSS px, growing 2 per pass,
  unbounded. Below the floor the harness now keeps the width pin (that
  part does land, and it is what stops the width axis from following),
  writes no ratio at all, and stays unpinned, so the free-axis derivation
  anchors height to the ORIGINAL attribute ratio and it settles on the
  second pass.

### Driver (blind review round 6)
- The pin helper no longer writes its tall wrapper height below the
  individual-transform floor (Chrome 104 / Firefox 72 / Safari 14.1),
  the same way it already skips it under reduced motion. The
  `@supports not (translate: 0)` net in `styles/pin.css` releases
  `.sv-stage` there (position static, height auto, overflow visible), so a
  pinned section renders at its natural height, but `height: 320vh` stayed
  on the wrapper: with JS on, the content sat at the top of the box with
  two blank viewports under it. The helper asks
  `CSS.supports('translate', '0px')` and takes the in-flow branch when the
  answer is an explicit `false`; an engine too old to answer at all is
  also too old for the `@supports` rule that releases the stage, so the JS
  and the CSS always agree on which side of the floor the page is.
- `prefersReducedMotion()` and `scrollToScene()` no longer return a stale
  `false` before anything has ever been tracked. The `reducedMotion` flag
  was only ever set inside `init()`, which only `track()` calls, so asking
  either function first picked the wrong answer for a reduce user. Both now
  read the media query lazily on first use, and fall back to the live
  `reducedMotion` flag once `init()` has wired its change listener (the
  `addListener` fallback for pre-Safari-14 `MediaQueryList` is untouched).

### Docs (round 6, ADU-159)
Docs read against the code merged by the seven round-6 code tickets.
- The TypeScript import block that pulls in the CSS is no longer labeled
  `app/globals.css`, a CSS file that cannot hold `import` statements: it
  now points at `app/layout.tsx` (or any entry file).
- "React renders zero times during scroll" is scoped to what actually
  never re-renders: `useScenes` and `useSlider` hold the current index in
  state and re-render on a discrete change, never per frame.
- `sv-split-rise` is documented as static only below `:is()` support,
  where its `:is()`-written rule is dropped whole; between `:is()`
  support and the individual-transform floor the rule still matches and
  its `opacity` declaration still transitions, so the text fades in
  without rising.
- The pin helper's "returns to flow" list gained a third case: below the
  individual-transform floor, matching ADU-158 (it already covered no-JS
  and reduced motion).
- `compat()`'s `sv-rail` fallback is documented as it actually behaves:
  it ignores `--sv-rail-start`, starts at `translateX(0)` instead of
  entering from offscreen, and is stationary whenever the track's own
  width equals the viewport, instead of the previous "gives it back its
  own scroll-linked travel" claim.
- Checked against the round-6 code tickets: the Modal SSR sentence
  ("open ones open") and the pin-helper wrapper description already
  match ADU-156 and ADU-158, no further change needed there beyond the
  floor case above.
- Second pass (verifier finding on 213c15b): the compat header comment in
  `src/compat/index.ts`, which `tsc` emits verbatim into the published
  `dist/compat/index.js`, still opened with the old flat claim, three
  presets stay static, one reason each, including `sv-split-rise` with
  no fallback rule at all. It now carries the same two-band split as the
  README sentence above: `sv-deck` and `sv-spread` stay static, one
  reason each; `sv-split-rise` is static only below `:is()` support and
  fades in without rising above it. The README's closing "stay static or
  progressive" list also still named `sv-split-rise`, reading like the
  old flat claim; it is dropped from that list now that the paragraph
  above it already carries the nuance.

### Slider (blind review round 7)
- The active slide is the one nearest the viewport centre in PIXELS. The
  argmin divided each distance by that slide's OWN width first, so a wide
  slide always looked nearer than a narrow neighbour: with a 100px slide
  beside a 300px one (centres 50 and 250, midpoint 150) the active flipped
  to the wide slide at centre 101. `sv-active`, `--sv-slide`, `onSlide`,
  the glide a drag release lands on and the wheel settle all took that
  index. `--sd` is unchanged, still normalized by each slide's own size,
  which is what the CSS reads; an exact tie still keeps the first slide.
  Sliders whose slides are all the same width read exactly as before.
- A destroyed slider stops moving. `destroy()` set no flag, so `next`,
  `prev`, `goTo` and `seek` still scrolled the container from a frame of
  their own, on geometry nothing measures any more. Every command is a
  no-op after `destroy()` and nothing schedules a frame, so a scroll or an
  observer record already in flight measures nothing either.

### React (blind review round 7)
- `<Slider>`'s imperative `destroy()` drops its handle instead of keeping
  it, so the autoplay interval (which reads the handle on every tick and
  returns when there is none) stops advancing a slider the consumer has
  destroyed. With the core fix above, a destroyed slider is inert from
  either side.

### Canvas (round 8, ADU-189)
- `destroy()` was neither idempotent nor exception-safe. A second call ran
  the consumer's own `setup()` cleanup again (disposing a renderer or a GPU
  buffer twice can itself throw), and a cleanup that threw skipped both the
  `ResizeObserver`/`IntersectionObserver` disconnects and all three
  listener removals (`visibilitychange`, the reduced-motion query, the DPR
  query), leaking them for the life of the page. `destroy()` now returns
  early on a second call, nulls the stored cleanup before running it, and
  runs the teardown in a `finally`, so a throwing cleanup can no longer
  skip it. The untouched twin of ADU-165, which fixed the same shape in
  the slider; no other module carries a consumer-supplied dispose callback
  a destroy path can re-run or skip past.
- The idempotency guard above had its own gap: a consumer calling
  `destroy()` reentrantly from inside its own `setup()`, before `setup()`
  has returned a cleanup (bailing out of a WebGL context that failed to
  create is exactly this shape), ran `destroy()` with nothing yet stored to
  clean up, and then had the cleanup `setup()` went on to return stashed
  into the same slot regardless, where nothing ever reads it again: a
  second, explicit `destroy()` hit the early return above and the consumer's
  dispose never ran. Fixed by running that cleanup immediately when
  `destroyed` is already true instead of stashing it. The `finally` above is
  still a flat sequence, so a throwing teardown step would skip the ones
  after it and mask the original error; documented, not fixed, since the
  observers' `disconnect`, `removeEventListener` and the media query removal
  are all specified never to throw.

## 1.13.0 (2026-09-05)

Second source-level review round (Kimi K3 and Codex gpt-6-astra on a clean
clone): every confirmed defect fixed, plus the primitives both reviewers
said premium sections would need.

### Sections in the fx gallery
- New category **Sections**: `hero-cinematic`, `timeline-scrub`,
  `sticky-steps`, `stats-countup`. Whole blocks built only on the presets,
  each with CSS / Tailwind / React snippets and a CLI component
  (`npx scrollvars add <slug>`). They are also the dogfooding that surfaced
  the items below.

### Driver
- `--sv-page` (0..1 through the document) and `--sv-v` (signed velocity in
  viewport-heights per second, decays to 0 after the scroll stops) are written
  on `<html>` every frame: reading-progress bars and skew/stretch effects need
  no JS.
- `--sv-scenes` (the scene count) is written next to `--sv-scene`, so a
  progress bar is `calc(var(--sv-scene) / (var(--sv-scenes) - 1))` instead of a
  hard-coded divisor.
- Pin helper: `pin: '320vh'` / `data-sv-pin="320vh"` / `<Track pin="320vh">`
  sets the wrapper's height and `position: relative`; with the new `.sv-stage`
  preset (sticky viewport) the pinned skeleton is no longer hand-written.
  Under reduced motion the wrapper stays in flow.
- Culling now gives every entry one geometry pass on a scroll jump longer than
  a viewport (anchors, `scrollTo`, restored positions): an element carried from
  far below to far above no longer keeps stale variables.
- `html.sv-on` lands last, after every observer is built, and never if the
  `ResizeObserver` constructor throws. `window.__scrollvars` marks arrival.
- `scrollToScene` uses the rendered height (like the pin math) and takes an
  optional `root` scroller; `useScenes().goTo` passes the root it tracks with.

### React
- `<ScrollVarsBoot />` renders a tiny pre-paint script: `html.sv-on` is set
  before first paint (no visible-then-hidden flash on SSR entrances) and
  removed again after 3s if the driver never booted. Place it first in
  `<body>`.
- `<Scenes>` forwards `root`, `enter`, `exit`; its stage is `.sv-stage` and
  returns to flow under reduced motion.
- `useTrack` only hands the driver an `onScene` when the consumer has one, so
  `once` entries are actually released.
- Slider autoplay wraps when the rail reaches its end (multi-slide views never
  make the last slide active), and pauses on focus anywhere in the shell
  (arrows, dots, pause button), not only inside the rail.

### Slider
- Geometry is rect-based and container-local: active slide and `goTo` targets
  no longer depend on which ancestor is the `offsetParent`; RTL mirrors
  against the container's own right edge.
- `goTo(i, false)` after `seek()`, and a plain click that interrupted a glide,
  restore the authored `scroll-snap-type`.
- Orientation/drag classes follow the options on re-init and are removed on
  `destroy()`; `state().position` is clamped to `count - 1`.

### Presets and no-JS
- Knob defaults moved from `.sv` to `:where(:root)`: an author `:root`
  override now wins regardless of import order.
- `.sv` resets `--sv-pin`, so a nested tracked element never reads an
  ancestor's pin clock.
- Split entrances get the nested-ownership guard; the reduced-motion override
  now outranks the nested hide rules.
- SSR markup carries `.sv` before the driver runs: rail, deck, curtain,
  reading, counter, spread and acts render their finished state without JS
  (`html:not(.sv-on)` rules). `--sv-r` is a registered property (initial 1).
- `.sv-tilt` angle is a knob: `--sv-tilt` (default 14deg).
- compat: the transform fallback honours `sv-skip`; the ResizeObserver shim
  delivers an initial observation, so legacy canvases start.

### Sticky headers, knobs, interaction
- `--sv-pin-offset` (one CSS declaration, e.g. `:root { --sv-pin-offset: 64px }`)
  is read by both `.sv-stage` and the driver's pin math, so a sticky header no
  longer breaks pinned sections; `scrollToScene` accounts for it too.
- `--sv-rail-start` (rail inside a narrower stage), `--sv-reading-floor`
  (default .55 so unread words keep a 4.5:1 contrast; was .13), and the
  sv-range clock now lives on the container at zero specificity, so
  `.mine { --sv-clock: var(--sv-t) }` overrides it.
- StickySteps marks inactive shots `inert` + `aria-hidden` after mount, so a
  crossfaded shot cannot keep focusable links.
- e2e invariants: an SSR-shaped sweep (`.sv` already on the markup, no JS) and
  a reduced-motion sweep (JS on, whole page scrolled) over every fx page.
- Home page: `<main>` landmark; the guided-reading demo passes color-contrast.

### Canvas, debug, CLI, split
- `setup()` may return a cleanup, called on `destroy()`; `resize()` also runs
  once after `setup()`. The three-scene component disposes its GPU resources.
- Debug overlay renders element names as text, never markup.
- CLI: a flag value is not a positional (`add --dir src/ui marquee` works),
  registry file names are validated as bare names, the fallback registry is
  the real second host.
- `split(…, { by: 'char' })` splits on grapheme clusters when
  `Intl.Segmenter` exists (emoji and combining marks stay whole).
- `--mx`/`--my` are clamped to -1..1.

### Review round 2 (Codex gpt-6-astra, 17 findings, all fixed)
- Entrance presets now animate from an inherited `--sv-live` flag (0/1) owned
  by the nearest tracker: nesting can no longer leak a parent's state, server
  markup with `data-sv` hides before `scan()` runs, and the reduced-motion
  override always wins. The compat fallback uses the same flag.
- Pinning: `--sv-pin-offset` is read for every pin consumer (`scenes`, `onPin`
  too) and re-read on `refresh()`; the pin helper remembers the inline styles
  it replaced, restores them on untrack, and follows live reduced-motion
  changes. `<Scenes>` no longer writes inline stage geometry: it uses the pin
  helper and `.sv-stage` (needs `styles/pin.css`). `.sv-stage` gets
  `--sv-stage-height` for inner scrollers; rails wrap under reduced motion.
- `once` entries settle `--sv-view` before releasing (a drift child measured
  below the screen stayed invisible); `--sv-page`/`--sv-v` keep following the
  scroll after the last entry released itself.
- Slider: geometry from offset chains again (transform-immune, container-local,
  RTL against the content width), reads batched before writes, slides observed
  for size and childList changes, drag only on the primary button and never on
  native controls (inputs, textareas, selects, contenteditable).
- React: `onScene` added later now re-tracks; `<Split by="char">` renders the
  graphemes it counts; `inert` is rendered as `inert=""` so React 18 keeps it;
  Modal falls back to an open static panel without `showModal`.
- Toggles: `aria-expanded` synced on boot and across every trigger of a target.
- Gallery: every pinned preview and installed component uses the pin helper and
  `.sv-stage` (no hand-written sticky skeleton anywhere); the hero stops its
  pointer drift and exit scaling under reduced motion; StickySteps drops
  `inert` when it stacks the shots; Tailwind tabs carry the selector classes
  their JS/CSS needs; minimum versions bumped for the migrated components.
- CLI: the registry fallback is the committed copy on GitHub (an independent
  host), `--dir` without a value is rejected, the registry is only fetched for
  `list`/`add`.
- Docs: Firefox floor is 78 (`:is()`), the bundle ratio is computed from the
  stamped sizes, throttle wording is honest (set through CDP, nominal), the
  scroll-tracking rAF claim is scoped, pointer has two listeners, page
  variables document their clamp and decay, the canvas example guards `ctx`.

### One source for the shared facts
- `scripts/docs-data.mjs` holds the variables table and measures every size at
  build; `docs-stamp.mjs` writes them into README and AGENTS between markers,
  generates `demo/llms.txt` from AGENTS.md, and `bench-tables.mjs` stamps the
  benchmark table into README, AGENTS and the bench page from the committed
  results. CI fails if any of those files differ from what the build produces.
- The gallery data (`EFFECTS`, `COMPONENTS`) lives in `scripts/fx-data.mjs`
  with no side effects; `test/cli-components.test.mjs` compiles all 16 CLI
  components with esbuild, renders the React ones with `react-dom/server`, and
  checks that each uses the preset vocabulary its preview shows.
- Registry entries declare `requires` (stylesheets, peer deps, minimum
  scrollvars); `npx scrollvars add` prints them and checks the installed
  version.
- Slider tests for a rail inside a positioned ancestor and for a vertical rail.

### Docs and release
- Sizes are measured at build and stamped into every document (see README for
  the current numbers; nothing is typed by hand any more). Stale claims fixed
  across README, AGENTS, llms.txt, docs, fx
  recipes (rotating-words `vertical-align`, split's sr-only span, "SplitText
  gap", `.sv-open` toggle example, Parallax reads `--sv-view`, and more).
- e2e invariants: every fx page must render every text node without JS; the
  split invariant follows the sr-only contract.
- Release workflow fails when CHANGELOG has no entry for the tagged version.
- Brand name is written ScrollVars in prose; every identifier stays
  `scrollvars`.

## 1.12.4 (2026-09-02)

- `<Split>` / `split()` drop `aria-label` on the container (axe
  `aria-prohibited-attr` on generic roles); the full text stays in a
  visually-hidden span, the animated spans are `aria-hidden`.

## 1.12.3 (2026-09-01)

Fixes a regression from 1.7.0's drag-click fix.

- **Slider: mouse drag no longer fights native text-selection.** 1.7.0
  removed `preventDefault()` from `pointerdown` to stop drags from eating
  clicks on links/buttons inside slides. But that also removed the only
  thing killing the browser's native text-selection-drag. Without it, a
  drag that leaves the container starts a live selection, and the browser
  auto-scrolls toward the pointer to extend it. Fighting the slider's own
  `scrollLeft` writes every frame (visible as jitter/trembling, plus
  visible text selection). Fix: `preventDefault()` is back on `pointerdown`
  for mouse (kills selection-drag at the source, same as pre-1.7.0), and
  the one real side effect. It also suppresses the browser's native
  focus-on-mousedown. Is repaired by manually restoring focus in `endDrag`
  when the press turns out to be a plain click, not a drag. The click event
  itself was never suppressed by `preventDefault` (confirmed in 1.7.0's own
  fix notes), so link/button activation was never at risk. Only focus was.
  Verified with a real mouse-drag test (Puppeteer, dragging 480px outside
  the slider): zero text selection, smooth monotonic `scrollLeft`, no
  jitter. And a unit test locks in both halves (preventDefault fires,
  focus restores on a plain click) so this can't quietly regress again.


## 1.12.0 (2026-08-27)

The last executable items from the review panel's path-to-9, plus launch
readiness.

- **`split`: SplitText-lite.** Word/char spans carrying `--sv-order`
  (+ `--sv-count` on the container), aria-label kept, spans aria-hidden,
  fully restorable. Zero-wrapper: `data-sv-split` / `data-sv-split="char"`.
  React: `<Split>` renders the spans **on the server**. No client-side
  splitting, no layout shift, no hydration flash. New presets `sv-split` /
  `sv-split-rise`; pairs with `sv-reading` for scrubbed text. New fx entry
  + CLI component: `split-reveal`.
- **Progressive-enhancement invariants, proven.** `npm run test:e2e`
  (harness): no-JS renders complete, nothing is ever hidden before
  `html.sv-on` exists (probed from the first frame), attribute knobs land.
  All green; wired into CI.
- **CI** (GitHub Actions): build + 21 unit tests + the e2e invariants on
  every push/PR.
- **Low-end bench profile**: the harness runs the 4× CPU-throttle suite
  headful (headless Chrome never produces frames under throttle. Found
  the hard way) and publishes `results/throttled-4x.json` + a table on
  /bench/.
- **Launch assets**: og:image social card on every page, README hero GIF
  (generated by `harness/make-gif.mjs` from a real scroll), and
  `article/launch-kit.md`, Show HN, thread, newsletter pitch, ready to
  fire on publish day.
- `ScrollVarsBoot` mounts the debug overlay on `?sv-debug` (code-split;
  free otherwise). Release tags v1.7.0–v1.11.1 pushed.


## 1.11.1 (2026-08-27)

- **`sv-words` aligns to the text baseline at any host line-height.** The
  preset used `vertical-align: bottom`, which only lined up when the host
  line-height was ~1.15. Inside a loose-leading heading the rotating word
  sat visibly low. Now `vertical-align: baseline`: an inline flex container
  exports its first item's baseline, so the rotator self-aligns by
  construction (all words share the same metrics, so every word lands on
  it).
- Demo: the pizzeria wheel is height-driven. Px caps removed (58vh/46vw),
  and on vertical screens (`max-aspect-ratio: 1/1`) the height rules
  (52vh radius, 48vh slices) so the wheel keeps ~60vh of presence instead
  of shrinking with the width.


## 1.11.0 (2026-08-27)

- **Attribute knobs: the style attribute is now optional everywhere.**
  `data-sv-order`, `data-sv-distance`, `data-sv-from`, `data-sv-to` become
  the matching CSS variables, written once on mount by `scan()` (and for
  route-change nodes via the existing MutationObserver). Never in the
  frame loop, zero global CSS, safe for mapped CMS content where Tailwind's
  JIT can't interpolate classes. When typed CSS `attr()` settles, this
  mapping becomes pure CSS.
- Docs: "One knob, four ways" before/after table (style attr vs attribute
  vs React prop vs arbitrary class vs sv-stagger) and a Browser support
  section on /docs/. The full matrix plus the `scrollvars/compat` legacy
  answer (previously README-only).
- fx: staggered-reveal and sequenced-scrub teach the attribute path in
  their Tailwind tabs; the sequenced-scrub preview dogfoods it.


## 1.10.0 (2026-08-27)

"Hitting the ceiling never means rewriting". Interop as official recipes,
not adapter modules (no new dependencies, the scope boundary stays).

- **Canvas harness works for WebGL/Three**: `mountEffect(canvas, { context:
  null })` grabs no 2D context. Your renderer owns the canvas, the harness
  keeps the lifecycle (DPR cap, resize, pause offscreen/hidden, delta-time,
  reduced-motion, cleanup). `EffectFrame` gains `canvas`; `ctx` is null in
  that mode. `useCanvasEffect` forwards the option.
- **Two Interop fx recipes** (new gallery category): `gsap-scrub`: author a
  GSAP timeline, scrub it via `onPin` (one listener, one writer; with the
  honest per-page bundle note) (and `three-scene`) a Three.js torus-knot
  scrubbed by the pin on the harness. Both ship CLI components
  (`npx scrollvars add gsap-scrub|three-scene`).
- Docs interop section links both recipes; AGENTS/llms carry the pattern.


## 1.9.0 (2026-08-26)

Driven by the third blind-review round (Kimi K3 moved 7 → 8; the remaining
criticisms became this release).

- **Offscreen culling.** Entries far outside a one-viewport margin skip the
  per-frame `getBoundingClientRect` (IntersectionObserver-gated; entries
  with a custom `root` and `once`-completed entries are handled; tested).
  Long pages stop paying for sections nowhere near the screen. And the
  main bench scenario's total CPU flipped in scrollvars' favor with it.
- **`scrollvars/debug`**: the devtools story: a dev overlay listing every
  tracked element with live variable values, live badge, tracked-element
  outlines and click-to-scroll. `import('scrollvars/debug').then(m => m.debug())`.
- **Honest numbers, mechanically enforced.** The demo footer version and
  wire sizes are stamped at build time from the actual dist (esbuild+gzip);
  fx pages compute the engine size from the real bundle; every stale
  1.2/3.1 KB claim corrected to measured values (driver 1.4 KB gz, full
  core 3.9, slider 1.7, core.css 1.8). The demo page's inline driver is
  labeled as that page's teaching copy. The package in dist/ is the
  source of truth.
- **Benchmark: medians of 5 runs** (was 3) and a precisely-stated headline
  claim: not faster frames: the same frames for ~12× less bundle and a
  fraction of the heap, with total CPU trading blows (scrollvars wins
  shallow scenarios, batched GSAP wins deep subtrees; both published).


## 1.8.0 (2026-08-25)

The "path to 8" release. Everything the external review panel said would
move the score, minus the parts only the real world can provide.

- **`sv-range`: sequenced scrub choreography without a timeline.** Each
  child of `.sv-range` derives `--sv-r` (0..1) from its `--sv-from`/`--sv-to`
  slice of the parent clock (`--sv-pin`, else `--sv-t`); `sv-range-rise` is
  the ready-made flavor; `mapRange(t, from, to, ease?)` is the JS twin for
  `onPin`/`onTravel` consumers. Reduced motion settles ranges at the end
  state. New fx entry + CLI component: `sequenced-scrub`.
- **Custom root scroller + configurable live band.** `track(el, { root })`
  measures against an inner scroll container (root rects read once per root
  per frame, still strictly read-then-write); `enter`/`exit` options: also
  `data-sv-enter`/`data-sv-exit` and `<Track>` props. Replace the
  hard-coded 75%/25% band.
- **Reproducible benchmark.** `demo/bench/harness` (puppeteer-core + CDP)
  reproduces every published number: symmetric pairings both directions
  (idiomatic AND batched one-trigger-per-section GSAP), medians of N runs,
  rotated engine order, calibrated CPU throttle, raw JSON committed. The
  bench page tables regenerate from `results/latest.json`
  (`scripts/bench-tables.mjs`); the inline engine resyncs from dist on
  every build; the 900-trigger Lighthouse row is labeled a stress test.
- **The style-recalc curve, published. Including where it loses.**
  `?deep=N` gives every box a realistic subtree; at 50 nodes/box the
  batched GSAP build wins total CPU. The measured curve and the authoring
  rule (keep tracked elements thin; static content next to, not inside,
  animated elements) are on /bench/ and in the docs.
- **APG carousel contract for `<Slider>`.** `role=region` +
  `aria-roledescription` + `label` prop; per-slide "i of n" annotation in
  place; visible pause/resume control whenever autoplay is set (`.sv-pause`,
  arrow knob family); `aria-live` off-while-rotating / polite otherwise;
  `renderDot` keeps focus indication. Marquee pauses on focus-within.
- **First React-layer tests**: `renderToStaticMarkup` in plain node (also
  guards SSR): carousel contract, `<Scenes>` prop hygiene, inert marquee
  duplicate. react/react-dom join as devDependencies.
- **Human docs** at /docs/: quickstart, the six variables, every export,
  preset vocabulary, coming-from-GSAP mapping (with the honest "keep GSAP"
  row), when-NOT-to-use, interop recipe, per-surface accessibility
  contract, troubleshooting, rendered changelog. Version visible on every
  gallery page.


## 1.7.0 (2026-08-25)

Fix release driven by a four-model external review panel (blind site
evaluations + source-level code reviews). Everything below was independently
found by at least one reviewer and verified before fixing.

Core driver:

- **Nested scrollers now work**: the scroll listener runs in the capture
  phase, so scrolls inside modals and inner panels reach the driver.
- **Travel/pin math uses the rendered box** (`rect.height`) instead of
  `scrollHeight`. Progress reaches 1 on fixed-height elements with
  overflowing content.
- **`once` is fire-and-forget**: after going live, entries with no
  continuous outputs (travel/pin/scenes/callbacks) stop paying the
  per-frame `getBoundingClientRect`. `--sv-view` freezes at its last value.
- `scan()`/`toggles()` no longer throw during SSR (default params were
  evaluated before the environment guard).

Slider:

- **RTL support**: positions normalize to logical coordinates (0 → range
  from the content start); arrows mirror; progress/seek/goTo correct under
  `dir="rtl"`.
- **Drag no longer eats clicks**: a 5px movement threshold separates clicks
  from drags (links and inputs inside slides work again, focus included),
  and the accidental click after a real drag is swallowed.
- Arrow keys typed into inputs inside slides no longer move the carousel.
- A glide interrupted by touch (or with `drag: false`) no longer leaves
  native snap suspended forever.
- The scripted glide respects `prefers-reduced-motion` (jumps instead).

React:

- **`<Slider ref>` works**: the forwarded handle is a stable proxy that
  delegates at call time (it was permanently `null`).
- **`<Scenes>` honors its declared props**: tracking options reach the
  driver, `onScene`/`as`/VarProps work, and nothing leaks to the DOM.
- `useSlider` forwards `onScroll` (it was typed but dropped).
- Responsive `perView` breakpoints emit in ascending order (`{xl: 4, md: 2}`
  no longer lets `md` win at desktop widths).
- Autoplay pauses while keyboard focus is inside the slider (WCAG 2.2.2)
  and reads the last IntersectionObserver record, not the first.
- The Marquee duplicate is `inert`, so its links aren't tabbable.

Canvas & pointer:

- Canvas re-applies its backing size when the monitor's DPR changes.
- Pointer tilt can't get stuck by a leave racing the queued rAF.
- Legacy-guard consistency: the canvas module uses the same optional
  `matchMedia` listener calls as the driver.

Styles & packaging:

- Nested tracked sections: a live ancestor no longer reveals entrance
  children of an inner `.sv` that hasn't gone live (plain CSS3 selectors).
- Reduced motion on `sv-deck` lays cards out in flow instead of leaving
  them stacked in one grid cell.
- The compat drift preset carries an `opacity: 1` fallback where `max()`
  doesn't parse (the advertised Chrome 61 floor).
- `sideEffects` glob is `**/*.css`, `styles/*.css` imports survive
  tree-shaking.
- `engines.node >= 18`; `pretest` builds before testing; `prepare` builds
  on git installs (`npm i github:aduptive/scrollvars` works).
- README: documented stagger default corrected to 90ms (matches the CSS).


## 1.6.0 (2026-08-24)

- **The fx gallery** (/fx/ on the demo site): a growing library of
  copy-paste effects. Live preview, Tailwind + CSS + React tabs with copy
  button, knobs documented, per-gallery llms.txt for AI ingestion. Built
  from one data file (`scripts/fx-build.mjs`); 8 seed effects.
- **shadcn-style CLI**: `npx scrollvars list` / `npx scrollvars add <slug>
  [--dir] [--force]`. Fetches a remote registry (registry.json on the fx
  site) and writes a complete component file into the project; the library
  grows without package releases.

## 1.5.0 (2026-08-24)

- **React component kit**: `<Slider>`/`<Slide>` (the Swiper replacement:
  `perView` number or responsive map. Breakpoints ARE media queries; `gap`,
  `span` per slide, arrows/dots chrome on stable classes + var knobs,
  `prevIcon`/`nextIcon`/`renderDot`, external control via ref exposing the
  full SliderHandle, `autoplay` that pauses on hover/offscreen/hidden),
  `<Marquee>` (infinite strip, the honest answer to Swiper loop),
  `<Accordion>` (native details + interpolate-size animation, exclusive
  groups via name), `<Modal>` (native dialog + sv-pop).
- CSS: `sv-cols` column sizing (`--sv-per-view`, fractional = peek;
  `--sv-span` per slide), slider chrome, `styles/ui.css` (marquee,
  accordion). `useSlider` now returns the handle and accepts
  duration/axis.

## 1.4.0 (2026-08-24)

- **`sv-spread`**: the deck-to-grid pattern: children live in their final
  flex row and a per-card translate collapses them onto the center (slight
  fan) while `--sv-spread` is 0. Two clocks: `.sv-spread-in` plays on
  arrival (sv-live + transition + stagger, re-deals on re-entry), or map
  the variable from `--sv-t`/`--sv-pin` to scrub it. Demo case 18, zero
  bespoke JS.

## 1.3.0 (2026-08-24)

- **`sv-acts`: multi-act timelines in pure CSS**: a registered custom
  property (`--sv-act`) transitions 0 → N when `sv-open` (click) or
  `sv-live` (scroll) arrives; acts are the same `clamp()` slices as the
  scroll scenes. One idiom for every timeline. Relative retiming
  (`--sv-acts-duration` rescales all acts), reversible and interruptible
  by construction. Older browsers snap to the finished state.

## 1.2.0 (2026-08-24)

- **Click states: the third input**: `toggles()` (wired by ScrollVarsBoot):
`data-sv-toggle`/`data-sv-target` flip a class, write `--sv-state` and keep
  `aria-expanded` in sync. Deliberately one click = one state change; no
  timeline engine.
- **Presets** (`styles/state.css`): `sv-pop`: popover/dialog/panel entry-exit
  via `@starting-style` + `allow-discrete`; `sv-words`: rotating words
  (clipped column, `--sv-word: n`), promoted from the pizza demo.

## 1.1.0 (2026-08-23)

- **Modular stylesheets**: `scrollvars/styles/core.css` (1.2 KB gz.
Entrances, stagger, drift, native view()-tier), `pin.css` (1.3 KB),
  `slider.css` (0.4 KB), `tilt.css` (0.5 KB). `styles.css` remains as the
  generated aggregate (scripts/build-styles.mjs). Fully backwards
  compatible, selector set verified identical.
- README "Pay for what you use": measured per-import JS costs: a typical
  reveal page ships ~2.2 KB gzipped total (track + core.css).

## 1.0.0 (2026-08-21)

- **API freeze.** The 0.x surface ships as-is: driver (`track`, `scan`,
  `slider`, `trackPointer`, `scrollToScene`, `refresh`), React layer
  (`Track/Reveal/Parallax/Scenes/Item`, `ScrollVarsBoot`, hooks, VarProps
  attribute API), `scrollvars/canvas`, `scrollvars/compat`, styles.css presets.
- `SliderState` type now exported from the root.
- Packaging: `sideEffects` scoped to CSS (bundlers no longer risk
  tree-shaking the stylesheet import), repository/homepage/bugs metadata,
  `prepublishOnly` runs build + tests. README gains a Defaults table.
- Demo tooling: `npm run demo:sync` / `demo:deploy` (dist-inline sync with
  parse checks; deploy re-points the alias and verifies). Driver core test
  suite added, 9 test files green.

## 0.12.0 (2026-08-20)

- **`scrollvars/compat`**: opt-in legacy module: ResizeObserver stub
  (viewport-resize backed), always-visible IntersectionObserver stub, and a
  `transform:`-based preset fallback stylesheet (no `:is()`/`clamp()`/
  `min()`). Free on modern browsers (feature-checks and exits). With the
  consumer bundler downleveling ES2020, the animated floor extends to
  ~Chrome 61 / Firefox 60 / Safari 11.

## 0.11.4 (2026-08-20)

- Keyboard navigation glides: arrow keys (axis-aware), Home and End go
  through the same soft glide instead of native 40px key-scroll steps +
  hard snap settle. Containers are made focusable (`tabindex=0`) so this
  works in Safari too.

## 0.11.3 (2026-08-20)

- Mouse drag: `preventDefault()` on pointerdown: native text selection was
  starting under the drag, and selection auto-scroll inside the scrollable
  container fought the gesture (the opposite-direction tug). Note: mousedown
  no longer focuses elements inside slides; irrelevant for carousels.

## 0.11.2 (2026-08-20)

- Wheel-assist only reacts when the gesture's dominant axis matches the
  slider's axis: vertical page scrolling over the carousel (trackpad
  gestures are always slightly diagonal) was triggering mid-gesture
  `goTo(nearest)` and made multi-slide travel feel impossible. Quiet
  window 160 → 200ms.

## 0.11.1 (2026-08-20)

- Mouse drag survives leaving the container: move/up listeners live on
  `window` while dragging (pointer capture on scrollable containers is
  unreliable). Release happens on the real pointerup, anywhere on the page.

## 0.11.0 (2026-08-20)

- **Slider state & chaining**: `state()` and `onScroll(state)` expose
  active/count/position (continuous)/progress/dragging/gliding; container
  gets `--sv-progress`; new `seek(progress)` for followers: two sliders
  chain in one line (Swiper controller/thumbs, unidirectional).
- **`axis: 'y'`**: vertical sliders (`.sv-slider-y`), same API.
- Size reference measured: this module 3.1 KB min / 1.4 KB gzip vs Swiper 11
  bundle 151 KB min / 42 KB gzip (+18 KB CSS).

## 0.10.3 (2026-08-20)

- Glide is now an exponential lerp (velocity ∝ remaining distance) instead of
  a fixed-duration tween: short drag-release settles feel as soft as long
  button glides. The tween front-loaded short distances and read as a dry
  snap. Retargets stay continuous. `duration` calibrates the settle time.

## 0.10.2 (2026-08-20)

- Rapid `next()`/`prev()` clicks accumulate: relative steps count from the
  in-flight glide destination (pending target), not from the lagging active
  index. Five fast clicks land five slides ahead.

## 0.10.1 (2026-08-20)

- Drag release and trackpad pan now glide too: snap is suspended via inline
  style for the whole interaction (authored inline value preserved), and a
  wheel-quiet debounce replaces the native fast settle with the slow glide.
  Skipped on instances authored with `scroll-snap-type: none`.
- Grab cursor only on draggable instances (`sv-draggable`), `drag: false`
  sliders no longer advertise a hand they can't honor.

## 0.10.0 (2026-08-20)

- **Slider glide**: own eased scrollLeft animation (ease-out) with a
  configurable `duration` for next/prev/goTo AND the drag release. Native
  smooth scrolling is fast and not configurable. Snap suspends while gliding
  (`sv-gliding`); pointerdown cancels the glide (the user takes over).

## 0.9.0 (2026-08-20)

- **`slider()` / `useSlider`**: featherweight Swiper: native scroll +
  scroll-snap, mouse drag, active-slide observer as `--sd` per slide (signed
  distance from center) + `.sv-active`, `next/prev/goTo`. `.sv-slider` CSS
  in styles.css.
- Demo case 10/11: regional scroll-snap (onLive toggles the magnet) and the
  four snap flavors. Case 12: the treasure map: scroll drives a camera along
  an SVG path with heading + counter-rotating stations.

## 0.8.0 (2026-08-20)

- **`onPin` callback** on TrackOptions and the React layer. Raw 0..1 across
  the pinned stretch, for frame scrubbing and camera tours. The demo already
  taught it; now the package has it.
- Demo code samples audited against the current API: `--sv-y` leftover fixed,
  deck/reading/counter samples now show the official presets, `useScenes`
  misuse replaced by `useTrack({ scenes })`.

## 0.7.0 (2026-08-19)

- **Zero-wrapper mode**: `scan()` tracks every `[data-sv]` element (options
  via `data-sv-once/pin/travel/scenes`) and follows DOM mutations;
  `<ScrollVarsBoot />` wraps it for Next.js layouts. Pages stay 100% RSC.
- **Presets promoted from the demo**: `sv-deck` (pinned card pile),
  `sv-reading` (guided reading), `sv-counter` (`@property` + `counter()`).
- ESM-correct relative imports (`.js` extensions). Dist now runs in plain
  Node too, not only through bundlers.

## 0.6.0 (2026-08-19)

- **`--sv-view` reformulated**: now the signed position relative to the live
  band. The same 75%/25% lines the `sv-live` class uses, so the variable and
  the class always agree. −1 with the top at the viewport's bottom edge,
  0 across the whole band, +1 once the bottom clears the exit line.
  Entrance/exit ramps are shorter than before (0.25 vh each); `sv-drift`
  reacts a touch snappier near the edges.
- `LICENSE` file and this changelog.

## 0.5.0 (2026-08-19)

- **Attribute API for React** (Felipe's feedback): `order`, `distance`,
  `stagger`, `duration`, `ease` as props on `Track`/`Reveal`/`Parallax`.
They compile to the CSS variables; new `<Item effect="rise|fade|slide-l|
  slide-r|drift|tilt">` for children. The variables remain the real API.
- `sv-rail` fixed on wide windows: starts one viewport offscreen right, ends
  right-edge aligned (`min()` keeps it moving when the track fits).

## 0.4.0 (2026-08-18)

- **`scrollvars/canvas`**: `mountEffect` / `useCanvasEffect`: lifecycle
  harness for ambient (time-driven) canvas effects: resize, DPR cap (2),
  delta-time loop, auto-pause offscreen and on hidden tab, live
  reduced-motion flag, full cleanup. Simulations stay in userland.
- `AGENTS.md` (agent-facing docs, shipped in the npm package) and
  `test/canvas.test.mjs`.

## 0.3.0 (2026-08-18)

- **Pointer module**: `trackPointer` / `usePointer` write `--mx`/`--my`
  (−1..1 from the element's center) with one delegated listener; `sv-tilt`
  preset (3D tilt + glare).

## 0.2.0 (2026-08-17)

- Vocabulary finalized: `--sv-view`, `--sv-t`, `--sv-pin`, `--sv-scene`,
  `.sv-live`, `html.sv-on` guard. React layer: `Track`, `Reveal`, `Parallax`,
  `Scenes`, `useTrack`, `useScenes`. Presets incl. `sv-curtain-l/r`,
  `sv-rail`, `sv-auto` stagger, `sv-view-*` pure-CSS tier.

## 0.1.0 (2026-08-17)

- First cut: single global driver (one passive scroll listener + one rAF,
  batched read → write), CSS variables as the entire output surface, zero
  React renders during scroll.
