# Changelog

All notable changes to **prop-for-that** are recorded here. Format follows
[Keep a Changelog](https://keepachangelog.com/). While the package is `0.x`, a
**minor** bump signals a breaking change and a **patch** bump signals a
backwards-compatible change (semver's `1.0.0`+ rules kick in at v1).

Only the published library (`dist/`) is versioned here; the demo and docs site
are repo-only and not part of the npm package.

## [0.7.12]

A code-review pass: four real bugs, one per-frame layout read, and a round of
deduplication across the shared helpers. No API changes — every fix below either
restores documented behaviour or is invisible to consumers.

### Fixed
- **`typed: true` no longer destroys every `--const-meta-*` value.** A source's
  *undeclared* value was always registered with the `<number>` default — but the
  `meta` plugin writes strings (`#3367d6`, `/cover.jpg`, `dark light`) under
  property names it only learns from the page at runtime, so it can't declare
  them. A `<number>`-registered property rejects a string at computed-value time,
  so every meta value computed to `0`. Undeclared **strings** are now left
  untyped; numbers, and any value whose source declares a `props` syntax, are
  registered exactly as before.
- **`img-color` no longer re-decodes on every scroll-back.** The swatch cache
  lived inside `start`, which the viewport gate re-runs on each re-entry — so the
  cache it was written for could never survive one, and every pass over a gallery
  re-ran `createImageBitmap` + `getImageData` per image. It's now a module-scoped
  `WeakMap`, keyed by the rendered source.
- **`video-color` no longer pins a core on a tainted canvas.** The sample
  throttle only advanced on a *successful* read, so a cross-origin video with no
  CORS grant re-drew and re-threw on every presented frame, forever. Taint is now
  latched: it gives up after the first failure, and still writes nothing.
- **`select`'s `--live-value-num` no longer goes stale.** It was written only for
  a numeric value and never cleared, so picking `"3"` and then a non-numeric
  option left `3` behind — and the documented `var(--live-value-num, …)` fallback
  could never apply again. It's now removed when the value isn't numeric.
- **`battery`** no longer leaves an unhandled promise rejection when `getBattery`
  exists but rejects (insecure context, or disabled by policy).

### Changed
- **`scroll-velocity` reads the scroll position in its scroll handler**, not
  inside the frame. Reading `window.scrollY` from a per-frame sampler forced a
  style+layout recalc every frame, since the previous frame's writes left style
  dirty — the one place the library broke its own read/write split. Behaviour is
  unchanged in a browser (scroll events fire before rAF in the same frame).
- **`pointer-local`, `motion`, and `orientation` now seed zeros**, like every
  other source, so their properties resolve on frame one instead of needing a
  `var(…, 0)` fallback. One thing to know: a `var(--live-local-pointer-x-ratio)`
  with *no* fallback used to invalidate its whole declaration until the first
  event, and now resolves to `0` — so gate on `--live-local-pointer-inside`
  rather than letting a highlight sit in the top-left corner on load. Global
  `pointer` still doesn't seed: the position genuinely isn't knowable before the
  first event.
- **`ctx.write(name, '')` removes a property**, for a value that stops being
  meaningful. Verified across Chromium, Firefox, and WebKit.
- **`visual-viewport` and `keyboard` share one set of visual-viewport
  listeners** via the new ref-counted `onVisualViewport`, instead of attaching
  their own. `battery` and `network` listeners are now passive, like every other
  source's.
- **`form-state`'s post-reset recompute rides the shared frame loop**, replacing
  the library's only stray `requestAnimationFrame`.

### Internal
- `core/window-events.ts` + `core/document-events.ts` collapse into
  `core/events.ts` — one ref-counted hub factory per target (`onWindow`,
  `onDocument`, `onVisualViewport`), plus an `onAll` helper for the per-element
  case. `observeResize` / `observeIntersection` likewise share one hub factory.
  Shared `noop` and `slug` replace ~15 and 2 copies respectively.
- The per-target queue maps in the writer are pooled and reused rather than
  reallocated every frame.

## [0.7.11]

### Added
- **Hoist an element's properties to an ancestor** — `propsFor(el, keys, { to })`
  and the markup twin `data-props-to="<selector>"`. The binding still *observes*
  the bound element; only its writes move. This is the general answer to the
  library's oldest constraint: custom properties inherit **downward only** and
  `@container style()` matches an **ancestor**, so an element could never share
  its own values with a sibling, nor style-query them itself. An `<img>` can now
  hoist `--live-loaded` onto its `<figure>`, where a skeleton overlay, the
  `<figcaption>`, and the image itself can all react to it.

  It's a binding-layer feature — sources need no changes, they just call
  `ctx.write` — so it works for **every** element source, including the ones the
  existing container-binding trick (`range`, `field`, `img`, `img-color`…) can't
  help: `size`, `visibility`, `pointer-local`, `truncated`. `to` takes a selector
  (resolved once at bind time via `el.closest()`) or an element. No match warns
  and falls back to writing on the element itself; `to` on a `global` source
  warns and is ignored. Two elements hoisting the same key onto one ancestor
  overwrite each other, so that warns too — keep it to one binding per key per
  target. Under `auto`, changing `data-props-to` rebinds every key on that
  element against the new target.

## [0.7.10]

### Added
- **`random` plugin** (element, opt-in) — the per-element variation CSS can't
  compute for itself yet: three independent rolls, `--const-random`,
  `--const-random-2` and `--const-random-3`, each a float in `[0, 1)`, written
  **once** on the `const` cadence. One rule then varies every element it's bound to
  (size from the first, tint from the second, `animation-delay` phase from the
  third) with no `:nth-child()` ladder; angles, ranges, buckets and coin flips are
  all derivable from these in `calc()`, so they aren't shipped. It sets
  `gate: false`: a gated element source's `start` re-runs on every viewport
  re-entry, which for a generator would hand an element a fresh value each time it
  scrolled back into view, mid-animation.
- **`configure({ randomSeed })`** — deterministic mode for `random`. Each element's
  rolls derive from the seed plus the element's position in the DOM rather than from
  a shared sequence, so the same markup renders the same "random" layout on every
  load (SSR, screenshot diffing, print), a rebind hands an element the values it had
  before, and bind order is irrelevant — reordering the tree is what reshuffles.
  Unset (the default) keeps rolling from `Math.random()`. The seed is read when a
  source starts, so it can differ between two `propsFor()` calls.
- **`data-props-seed` on the root `<html>`** — the markup mirror of
  `configure({ randomSeed })` for the `auto` entry, read once on load before any
  source attaches (`<html data-props-seed="42">`). `0` is a valid seed; a
  non-numeric value is ignored with a warning rather than seeding `NaN`.

## [0.7.9]

### Performance
- **Redundant writes short-circuit at the source boundary.** `ctx.write` now
  memoizes the last raw value (and the prefixed property name) per binding, so
  a write whose value hasn't changed — the steady state for most sources, e.g.
  `pointer-y` during horizontal movement or `scroll-direction` mid-scroll —
  exits after one Map lookup instead of paying the prefix concat,
  `String()` coercion, and Writer queue lookups every time (~5.6× faster on
  that path). Property names are now built once per binding, which also means
  prefix changes via `configure()` no longer affect already-attached bindings —
  consistent with `configure`'s documented "call before attaching" contract.
- **Allocation-free event/observer dispatch.** The shared `window`/`document`
  listeners and the page-wide Resize/IntersectionObserver callbacks no longer
  copy their handler set on every dispatch (previously one array allocation
  per event — including every `pointermove` — and per observer entry). Set
  iteration already tolerates handlers unsubscribing mid-dispatch.

## [0.7.8]

### Added
- **`ua` plugin** (global) + **`head` constants** — low-entropy device & browser
  identity JS can read but CSS can't, written **once** as `--const-ua-*`:
  `--const-ua-platform` (`macos` / `windows` / `linux` / `android` / `ios` /
  `chromeos` / `unknown`), `--const-ua-browser` (`chrome` / `edge` / `firefox` /
  `safari` / `opera` / `samsung` / `unknown`), `--const-ua-engine` (`blink` /
  `gecko` / `webkit` / `unknown`), `--const-ua-version` (browser major, `0` if
  unknown), `--const-ua-mobile` (`1` / `0`). Branch on the client with no JS or
  media query — `@container style(--const-ua-engine: webkit) { … }`. Prefers
  structured UA Client Hints (`navigator.userAgentData`, Chromium) and falls back
  to a best-effort `userAgent`-string sniff on Firefox / Safari. **Low-entropy
  only** — no `getHighEntropyValues`, device model, full version, or architecture,
  so it adds no fingerprinting surface beyond what request headers already expose.
  The same five constants are also written **synchronously** by the `head` entry
  (`import 'prop-for-that/head'`) for FOUC-safe, pre-paint branching. Typed mode
  registers the strings as `<custom-ident>` and the numbers as `<integer>`.

## [0.7.7]

### Added
- **`truncated` plugin** (element) — whether an element's own text is being
  clipped right now, the "is the ellipsis showing?" question CSS can ask a scroll
  container (`scroll-state()`) but never overflowing text. All `1`/`0`:
  `--live-truncated` (clipped on either axis), `--live-truncated-x` (inline clip —
  the classic `text-overflow: ellipsis` line, `scrollWidth > clientWidth`),
  `--live-truncated-y` (block clip — a `-webkit-line-clamp` / fixed-height
  `overflow: hidden` box, `scrollHeight > clientHeight`). Reveal a "more"
  affordance, tooltip trigger, or expand control only while text is actually cut
  off — `@container style(--live-truncated: 1) { … }` — and have it vanish the
  moment a wider box fits. Recomputed whenever the box resizes (the shared
  `ResizeObserver`); gated, so it pauses off screen like `size`. (#11)

## [0.7.6]

### Fixed
- **`head` scrollbar probe** — `--const-scrollbar-w` and `--const-scrollbar-thin-w`
  could report the *same* value on classic (non-overlay) scrollbar systems when a
  page styled non-root scrollers thinner (e.g. `:where(:not(:root)){scrollbar-width:thin}`).
  The probe never pinned `scrollbar-width` for the classic read, so that page rule
  bled in and made the "classic" measurement actually thin. The probe now sets
  `scrollbar-width:auto` inline, isolating the classic read from page CSS.

## [0.7.5]

### Added
- **`head`: `--const-scrollbar-overlay`** — the user's scrollbar preference,
  derived from the scrollbar-width probe already taken in `<head>`: `1` when
  scrollbars overlay content (reserve no layout space, appear on interaction),
  `0` when they're classic/inline (always shown, take up width). Lets CSS reserve
  a gutter only when it's actually needed —
  `padding-inline-end: calc((1 - var(--const-scrollbar-overlay)) * 12px)`. (#8)

## [0.7.4]

### Added
- **`keyboard` plugin** (global) — exposes the soft (on-screen) keyboard's
  geometry on `:root`: `--live-keyboard-open` (`1`/`0`), `--live-keyboard-height`,
  `--live-keyboard-width`, and `--live-keyboard-x` / `-y` / `-right` / `-bottom`
  (px, client coordinates), so layout can reflow around the keyboard with pure
  CSS — `padding-block-end: calc(var(--live-keyboard-height) * 1px)`. Reads exact
  geometry from the VirtualKeyboard API on Chromium/Android (it sets
  `navigator.virtualKeyboard.overlaysContent = true` while bound — so the
  keyboard overlays content instead of resizing the viewport — and restores it on
  dispose), and falls back to inferring the height from `visualViewport` shrink on
  iOS/Safari (approximate: a pinch-zoom can read as a keyboard). Seeds zeros where
  neither API is available. (#6)
- **`meta` plugin** (global) — writes every `<meta name|property + content>` on
  the page as a write-once `--const-meta-<slug>` (the slug lowercases the name and
  collapses runs of non-alphanumerics to a single dash): `--const-meta-theme-color`,
  `--const-meta-og-image`, `--const-meta-color-scheme`, etc. — so CSS can read page
  metadata directly (`background: var(--const-meta-theme-color)`,
  `background-image: url(var(--const-meta-og-image))`). First match wins per name,
  and a `media`-conditional meta is skipped when its query doesn't currently match,
  so the active `theme-color` variant is the one written. Read once at bind on the
  `const` cadence, so meta tags swapped in by a framework *after* bind aren't
  tracked. (#7)

Both are tree-shakeable opt-in plugins — import from `prop-for-that/plugins`, or
let `auto` load them on demand via `data-props-for`.

## [0.7.3]

### Changed
- **Event-driven element sources no longer use the off-screen viewport gate.**
  `range`, `field`, `field-state`, `form-state`, `select`, `color-input`, and
  `img` now run ungated (`gate: false`). They only write in response to user
  interaction or an image `load` — neither of which can happen while the element
  is off screen — so gating them only added an `IntersectionObserver`
  subscription plus a tear-down / re-seed cycle on every scroll in and out, for
  no benefit. Ungated, they seed their values once at bind time (so a `var()`
  resolves immediately, even for an element below the fold) and never tear down.
  Continuously-sampling element sources (`size`, `pointer-local`, `media`,
  `img-color`, `video-color`) stay gated, where pausing off screen is a real
  saving. Additive: off-screen elements that previously had no props until they
  scrolled in now carry their seeded values from the start.

### Fixed
- **`field-state` no longer loses its latched interaction history when its
  element scrolls off screen and back.** Because it was viewport-gated,
  scrolling the field out of view tore the source down and scrolling back
  re-ran it — re-snapshotting each field's "initial" value from the *current*
  (already-edited) value and clearing the dirty / touched / changed latches, so
  a field would report pristine again. Now that it runs ungated (above), the
  snapshot and latches are taken once and persist for the binding's life.
- **The shared frame loop no longer keeps `requestAnimationFrame` scheduled when
  no flush is owed.** Under a `liveHz` cadence cap a throttled frame rescheduled
  unconditionally; it now reschedules only while a continuous sampler is
  registered or a flush is still pending, so a one-shot write can't leave rAF
  spinning after its work is done. Any running rAF can demote compositor-driven
  CSS animations, so the loop is kept as quiet as possible — it idles the moment
  nothing needs it (regression test added).
- **`writer.forget()` no longer leaves an empty entry that makes a queued frame
  flush nothing.** When a source's disposal raced a same-frame write, the
  emptied target lingered in the pending map past the `flush` fast-path's size
  check; it is now removed so the frame is a true no-op.

### Performance
- Removed a per-frame array allocation from the loop's sampler dispatch
  (`[...frameFns]` → direct `Set` iteration). Samplers only ever remove
  themselves, which `Set` iteration handles safely, so the defensive copy was
  unnecessary on the one path that runs every frame.
- `motion` and `orientation` now attach through the shared, ref-counted
  `onWindow` helper instead of raw `window.addEventListener`, matching every
  other window-driven source — one real listener per event type for the page.

## [0.7.2]

### Fixed
- **`fps` source no longer writes `--live-fps` every frame.** The exponential
  moving average is still sampled per frame, but the value is now written at most
  every 250 ms (≤4 Hz). Writing per frame coupled the readout to the frame rate:
  each write invalidates `--live-fps` for the whole tree, and on a large reactive
  DOM that restyle is heavy enough to lower the frame rate, which changes the
  rounded value, which writes again — a feedback loop that spiralled FPS downward
  and accumulated restyle/GC work in Firefox until the tab hung and crashed (no
  user interaction required). The throttled write breaks the loop; an fps readout
  doesn't need to update faster than a few hertz anyway. Values are unchanged
  (rounded EMA), only the write cadence is bounded.

## [0.7.1]

### Fixed
- **`visibility` now tolerates near-1 intersection ratios.** The shared
  `IntersectionObserver` requests callbacks just below full containment, and the
  `visibility` source confirms full visibility from the entry geometry instead of
  requiring an exact `intersectionRatio === 1`. This fixes scroll-triggered
  reveal examples that could get stuck on Firefox mobile when the browser reports
  a ratio fractionally below 1.

## [0.7.0]

Breaking release (0.x minor): leaner defaults, viewport-aware sources, and a
truly zero-config `auto`. Migration notes are under each item.

### Changed
- **`auto` is now fully declarative and attaches nothing by default.** It no
  longer auto-attaches `viewport`/`pointer` to `:root`. Declare every source —
  globals included — with `data-props-for`, e.g. `<html data-props-for="viewport
  pointer">`. **Migration:** add the globals you relied on to the root `<html>`.
- **`auto` loads plugins on demand.** The first time a `data-props-for` key needs
  a plugin, its chunk is dynamically imported and registered, then the binding
  attaches — no more `registerPlugins()` / `prop-for-that/plugins` import when
  using `auto`. Because it lazy-loads via dynamic `import()`, load `auto` as a
  **module**: `<script type="module" src=".../auto.js">`. The classic
  `auto.global.js` drop-in is removed (it can't resolve lazy chunks from a CDN).
  **Migration:** switch the `auto` script tag to `type="module"` and drop any
  `registerPlugins()` calls that existed only to feed `auto`.
- **Element sources are viewport-gated.** An `element`-scoped source now runs only
  while its element is in the viewport (via the shared `IntersectionObserver`);
  off screen its work is torn down and its last values freeze in place, resuming +
  re-seeding on re-entry. Global sources and `:root` bindings are never gated; a
  source can opt out with `gate: false`. No API change for consumers.
- **The frame loop freezes while the tab is hidden** (`document.hidden`) and stays
  fully idle when nothing is changing — fewer stray `requestAnimationFrame`s on
  pages that aren't actively updating.
- Shared `ResizeObserver`/`IntersectionObserver` now support multiple subscribers
  per element (with last-entry replay to late subscribers), so an element can be
  watched by both a source and the gate at once.
- `auto` no longer caches a *failed* plugin load permanently — a transient failure
  (offline, blocked, flaky CDN) is dropped so a later request for the same key retries.
- `img-color` memoizes its extracted palette per rendered source, so the viewport
  gate re-running `start()` on scroll-in reuses the result instead of re-sampling the
  canvas when the image hasn't changed.
- Documented the auto path's two limits (light-DOM only / no shadow roots; needs a
  CDN that serves the `dist` tree verbatim) and the gate contract for source authors
  (`start()` is re-invoked on every viewport re-entry, so keep it cheap + idempotent).

### Added
- **`gate?: boolean` on `Source`** — opt an element source out of viewport gating
  (defaults on for `scope: 'element'`). `visibility` sets it `false`.
- **`isRegistered(key)`** exported from `prop-for-that` — whether a source key is
  currently registered.

### Removed
- **`pointer` is no longer a core source** — it writes on every `pointermove`, so
  it's now an opt-in plugin alongside `pointer-local`. **Migration:** under `auto`
  it just works (loaded on demand). Imperatively, `register` it first:
  `import { pointer } from 'prop-for-that/plugins'; register(pointer);
  propsFor(['pointer'])`.
- **`dist/auto.global.js`** (the classic, non-module `auto` drop-in) — see the
  `auto` note above.

## [0.6.4]

### Added
- **`nav-type` plugin** — `--const-nav-type`: how the user arrived at the page,
  written once as a string (`navigate` / `reload` / `back_forward` / `prerender`)
  from `performance.getEntriesByType('navigation')[0].type`. Pairs with style
  queries — `@container style(--const-nav-type: reload) { … }`. Global scope,
  `const` cadence; typed mode registers it as a `<custom-ident>`. Import as
  `navType` from `prop-for-that/plugins`. (Rides the batched writer, so it lands
  the frame after bind — read the same value in a `<head>` script if you need it
  before first paint.)

## [0.6.3]

### Added
- **`page-visible` plugin** — `--live-page-visible` (1/0): whether the tab is in
  the foreground vs hidden (backgrounded, minimized, switched away). The companion
  to `page-focused` — a *visible* tab can still be *unfocused* — for pausing video,
  polling, or expensive effects when the page is truly hidden. Global scope, seeded
  from `document.visibilityState` and driven by `visibilitychange`. Import as
  `pageVisible` from `prop-for-that/plugins`.
- Internal: `onDocument` — a shared, ref-counted passive `document`-listener helper
  (the document-target twin of `onWindow`), so sources can subscribe to
  `document`-only events (`visibilitychange`, Page Lifecycle `freeze`/`resume`)
  without a raw `addEventListener`.

## [0.6.2]

### Added
- **`page-focused` plugin** — `--live-page-focused` (1/0): whether the document
  has focus (tab frontmost *and* window focused). Goes `0` when the user switches
  to another tab, window, or app, and `1` on return. Global scope, seeded from
  `document.hasFocus()` and driven by window `focus`/`blur`. Import as
  `pageFocused` from `prop-for-that/plugins`.

## [0.6.1]

### Added
- **`--const-mem` head constant** — `prop-for-that/head` now writes
  `navigator.deviceMemory` (approximate device RAM in GiB) alongside the existing
  scrollbar/DPR/core constants. Chromium-only and deliberately coarse (`0.25`–`8`,
  capped for fingerprinting resistance); falls back to `0` where unsupported,
  matching the `--const-cores` pattern.

## [0.6.0]

### Changed
- **`pointer-local` plugin — renamed its emitted properties (breaking).**
  `--live-px` → `--live-local-pointer-x-ratio`, `--live-py` →
  `--live-local-pointer-y-ratio`, `--live-pointer-inside` →
  `--live-local-pointer-inside`. The element-scoped pointer now shares the
  `pointer` namespace with a `local-` qualifier, so the two are distinguishable
  at a glance, and the `-ratio` suffix keeps units honest against the viewport
  `pointer` source (`--live-pointer-x` is pixels; `*-ratio` is 0–1). The global
  `pointer` source's properties are unchanged. Update any CSS reading the old
  names.

## [0.5.2]

### Added
- **`select` plugin** (`prop-for-that/plugins`) — a native `<select>`'s state as
  numbers CSS can't reach: `--live-index` (`selectedIndex`, `-1` if none),
  `--live-option-count`, `--live-index-pct` (0–1), `--live-value-num`
  (`Number(value)`, only when the value is numeric), `--live-selected-count` and
  `--live-selected-pct` (0–1) — the multi-select tally `:checked` can't count.
  Turns a `<select>` into a discrete CSS state machine (slide a segmented
  indicator off `--live-index`) or drives layout off the chosen value. Bind the
  `<select>` or a container.
- **`color-input` plugin** (`prop-for-that/plugins`) — `--live-color`, the colour
  chosen in an `<input type="color">` as one sRGB hex string (CSS otherwise can't
  read it); extract channels with relative colour syntax / `color-mix()`. Typed
  mode registers it `<color>` so a picker can live-theme + interpolate a region
  with zero consumer JS. Bind the input or a container.
- **`field` plugin extended** — the per-reason `ValidityState` flags `:invalid`
  can't distinguish (each 1/0): `--live-value-missing`, `--live-type-mismatch`,
  `--live-pattern-mismatch`, `--live-too-long`, `--live-too-short`,
  `--live-range-underflow`, `--live-range-overflow`, `--live-step-mismatch`,
  `--live-bad-input`, `--live-custom-error`; plus a `maxlength` budget when one is
  set: `--live-remaining` (chars left) and `--live-fill-pct` (0–1) for a pure-CSS
  character counter. The budget props aren't written without a `maxlength`, so
  keep a `var()` fallback.

## [0.5.1]

### Added
- **`data-props-typed` on the root `<html>`** — the markup equivalent of
  `configure({ typed: true })` for the `auto` entry. Read once on load, it
  registers every written `--live-*` as an interpolatable `@property`, so
  HTML-only (`data-props-for`) pages can opt into typed values without touching
  JS. It's a boolean: `@property` is registered per name for the whole document,
  so typing is all-or-nothing and any attribute value is ignored (there's no
  per-key subset, matching the JS API). For per-property initial values, use the
  JS `configure({ defaults })`.

## [0.5.0]

### Changed
- **BREAKING: `imgColor` and `videoColor` now emit single `#rrggbb` colours, not
  RGB channels.** The canvas pixels are sRGB, so a hex colour is sufficient — and
  CSS already extracts whatever channels you need from a colour with relative
  colour syntax (`oklch(from var(--live-img) l c h)`) or `color-mix()`, so sharing
  separate channel props was redundant. This replaces the per-channel `r` / `g` /
  `b` props and the `l` luminance prop:
  - `imgColor`: `--live-img`, `--live-img-accent`, `--live-img-dark`,
    `--live-img-light`, `--live-img-avg` (each was `…-r` / `-g` / `-b` / `-l`).
    `--live-img-temp` is unchanged.
  - `videoColor`: `--live-video`, `--live-video-accent` (each was `…-r/g/b/l`).
  - To pick legible text, derive it from the colour's own lightness instead of a
    luminance prop, e.g. `color: oklch(from var(--live-img) clamp(0, (0.6 - l) * 9, 1) 0 0)`.
- Both sources now declare `@property` specs (`<color>`), so the live colours
  interpolate smoothly under `configure({ typed: true })`.

## [0.4.6]

### Added
- **`videoColor` now also writes a live accent colour** — `--live-video-accent-r`
  / `-g` / `-b` / `-l`, the most vibrant colour of the current frame (reuses the
  dominant when a frame is essentially grayscale). It reuses the same `palette()`
  pass as `imgColor`, so the accent is free beyond a few ops per sample; the plugin
  stays dominant + accent only (no full palette) since a six-swatch extraction
  isn't worth running 4×/second for an ambient glow. The existing
  `--live-video-r/g/b/l` dominant props are unchanged.

### Changed
- Removed the now-unused internal `dominantColor()` from `_color.ts`; `videoColor`
  reads the dominant from `palette()` instead. Internal only — no public API or
  behaviour change.

## [0.4.5]

### Added
- **`imgColor` now extracts a small palette**, not just the dominant colour. Four
  new swatches, each in the same `r` / `g` / `b` (`0`–`255`) + `l` (luminance
  `0`–`1`) shape as the dominant: `--live-img-accent-*` (the most vibrant colour —
  reuses the dominant for a grayscale image), `--live-img-dark-*` (darkest
  non-black), `--live-img-light-*` (lightest non-white), and `--live-img-avg-*`
  (the mean of every pixel, vs the dominant's mode). Plus a scalar
  `--live-img-temp` (`−1` cool … `+1` warm) from the image's red-vs-blue balance.
  All of it falls out of the **same single 16×16 bucketing pass** — no extra image
  reads — so it's barely dearer than before. The existing `--live-img-r/g/b/l`
  dominant props are unchanged.

### Changed
- `_color.ts` gains a shared `buildBuckets()` helper and a `palette()` extractor;
  `dominantColor()` (used by `videoColor`) is refactored onto it with no behaviour
  change, so `videoColor` stays dominant-only and pays nothing for the new palette.

## [0.4.4]

### Added
- **`videoColor` plugin** (`prop-for-that/plugins`, key `video-color`, element).
  The dominant colour of a playing `<video>` — `--live-video-r` / `-g` / `-b`
  (`0`–`255`) plus `--live-video-l` (relative luminance `0`–`1`) — for an ambient
  glow, scrim, or chrome that tracks the picture. Sampling rides
  `requestVideoFrameCallback` (so it stops when the video is paused, offscreen, or
  backgrounded) and is throttled to ~4 Hz on top; it seeds a paused/poster frame
  on attach and falls back to the `timeupdate` event where
  `requestVideoFrameCallback` is unavailable. The current frame is read 16×16 from
  a canvas, reusing `img-color`'s dominant-colour path. Cross-origin video needs
  `crossorigin="anonymous"` + permissive CORS headers, else the canvas is tainted
  and the plugin writes nothing (`var()` fallbacks stay safe). Bind the `<video>`
  or a container holding one.

### Changed
- The shared pixel-sampling + dominant-colour logic now lives in
  `src/plugins/_color.ts`, reused by both `imgColor` and `videoColor`. No
  behaviour change to `imgColor`.

## [0.4.3]

### Added
- **`formState` plugin** (`prop-for-that/plugins`, key `form-state`, element).
  Form-level **validity & completion** that CSS can't compute — `:invalid` matches
  one control, but CSS can't count across a form or gate on "all valid." Bind it to
  a `<form>` (or wrapper) for `--live-field-count` (controls subject to constraint
  validation), `--live-valid-count` / `--live-invalid-count`, `--live-all-valid`
  (`1`/`0` — the submit gate), and `--live-completion` (`0`–`1`: valid required
  controls ÷ required controls). Recomputes on `input`/`change`, and on the frame
  after a `reset`. Pairs with `field-state` (interaction history) and `field`
  (per-field `--live-valid`).
- **`imgColor` plugin** (`prop-for-that/plugins`, key `img-color`, element). The
  **dominant colour** of an `<img>` (CSS can't read pixels): `--live-img-r` /
  `--live-img-g` / `--live-img-b` (`0`–`255`) plus `--live-img-l` (relative
  luminance `0`–`1`, to pick legible text). Sampled 16×16 via `createImageBitmap`
  + `OffscreenCanvas` (decode/downscale off the main thread, ~256 px read back),
  with a `<canvas>` fallback; recomputed on load so a `src` swap updates it. Bind
  the `<img>` or a container holding one. Cross-origin images need
  `crossorigin="anonymous"` + CORS, else the canvas is tainted and the plugin
  writes nothing — so keep a `var()` fallback. Kept separate from `img` so the
  pixel-reading cost is opt-in.

## [0.4.2]

### Fixed
- **`fieldState`**: `--live-changed` now clears correctly after a form `reset`.
  It's recomputed against each control's *default* value synchronously, instead
  of re-read in a microtask — Chromium runs that microtask checkpoint *before* the
  reset event reverts the control values, so `--live-changed` stayed stuck on after
  a reset. `dirty` / `touched` / `submitted` already reset correctly; now `changed`
  does too (handles text inputs, `<textarea>`, `<select>`, and checkbox/radio).

## [0.4.1]

### Added
- **`fieldState` plugin** (`prop-for-that/plugins`, key `field-state`, element).
  The form interaction-history states libraries like Angular, Formik, and React
  Hook Form track but CSS pseudo-classes can't express — all `1`/`0`:
  `--live-dirty` / `--live-pristine` (has the user edited the field at all —
  latches on first `input`/`change`), `--live-touched` / `--live-untouched`
  (blurred at least once — latches on first `blur`), `--live-changed` (current
  value differs from the value at mount — un-latches when typed back), and
  `--live-submitted` (the owning `<form>` has been submitted — latches on
  `submit`, clears on `reset`). Both latch pairs reset when the form is reset.
  Bind a **single field** for that field's state, or a **`<form>` / wrapper** for
  the *aggregate* over every field inside it (dirty/touched/changed if *any*
  field is — like a framework form-group). Props land on the bound element, so a
  label, hint, or submit button can reveal an error or enable only once
  `--live-touched`/`--live-submitted`/`--live-dirty`. Validity stays in the
  `field` plugin; focus stays in `:focus`.

## [0.4.0]

### Added
- **`--const-scrollbar-thin-w`** from the `head` entry: the scrollbar width when
  `scrollbar-width: thin` is applied, measured alongside `--const-scrollbar-w`.
  Falls back to the classic width where `scrollbar-width: thin` isn't supported.
- **`img` plugin** (`prop-for-that/plugins`, key `img`, element). For `<img>`:
  `--live-natural-w` / `--live-natural-h` (intrinsic pixel size), `--live-loaded`
  (`1`/`0`), `--live-broken` (`1`/`0`). Bind the image or a container holding one
  (props land on the container, so a wrapper can show a skeleton while loading, a
  fallback when broken, or set `aspect-ratio` from the natural size). Seeds from
  `complete`/`naturalWidth` so already-cached images report correctly.
- **`cpuPressure` plugin** (`prop-for-that/plugins`, key `cpu-pressure`, global).
  Exposes the CPU's [Compute Pressure](https://developer.mozilla.org/en-US/docs/Web/API/Compute_Pressure_API)
  state as `--live-cpu-pressure`, an ordered tier (nominal=0, fair=1, serious=2,
  critical=3) — use it to back off expensive CSS work as the CPU gets busy.
  Chromium-only, secure-context, and gated by the `compute-pressure` Permissions
  Policy; feature-detects and no-ops (seeding `0` where supported, writing nothing
  where not) so `var(--live-cpu-pressure, 0)` is safe everywhere.

### Changed (breaking)
- **The auto-mode attribute is renamed `data-prop` → `data-props-for`.** The
  zero-config `prop-for-that/auto` entry now scans for, observes, and binds
  `[data-props-for="key1 key2"]` (was `[data-prop]`). Rename the attribute on every
  element: `<div data-prop="size">` → `<div data-props-for="size">`. The value
  syntax is unchanged — one or more space-separated source keys. The imperative
  `propsFor()` API is unaffected.
- **`visibility` is now full-element, and its latch moved off the `live`
  cadence.** Both signals key off *entire-element* containment instead of
  first-pixel overlap, and the write-once latch is renamed:
  - `--live-visible` (1/0) now flips only while the element is **entirely**
    within the viewport (previously: any part overlapping). Still reactive.
  - `--live-has-entered` → **`--const-has-entered`**. It's written once (latches
    to 1 the first time the element is entirely in view, never resets), so it now
    lives on the `const` cadence. Update CSS to read `var(--const-has-entered)`.
  - The shared `IntersectionObserver` gains `threshold: [0, 1]` to detect full
    containment. An element larger than the viewport can never be entirely
    visible, so neither signal ever turns on for it.

## [0.3.0]

### Changed (breaking)
- **Global `--live-*` writes go to an adopted stylesheet, not `<html>` inline
  style.** Values written to the document root through the rAF writer now land in
  a single constructable, adopted stylesheet rule (`:root {}`) instead of the
  `<html>` element's inline `style`. This stops the per-frame churn from making
  the DevTools Styles panel unusable and from flashing the Elements tree.
  **Consumption is unchanged** — the rule targets `:root`, so `var()` / `calc()`
  still inherit and resolve identically. What changes: reading
  `document.documentElement.style.getPropertyValue('--live-*')` directly no longer
  reflects these values (read computed style instead), and their cascade origin
  moves from inline to an author rule (so author CSS / `!important` can now win).
  Falls back to inline style where constructable stylesheets aren't supported
  (older engines, SSR). **Element-scoped** writes are unchanged (still inline), and
  the synchronous `head` entry still writes its `--const-*` constants inline by
  design (first paint).

### Added
- **`pause()` / `resume()`**: freeze the shared frame loop (no sampling, no
  flushing) so live values hold steady — handy for inspecting them in DevTools
  without churn, or halting work in a backgrounded tab — then pick back up.
  Bindings stay attached; both are idempotent.
- **`configure({ liveHz })`**: optional cap (in Hz) on how often the loop samples
  and flushes. Unset (default) runs every animation frame; e.g. `30` coalesces
  writes to ≤30/sec for fewer style recalcs and a calmer DevTools panel, at the
  cost of update smoothness. Throttles the whole loop, so per-frame samplers
  (`fps`, `scroll-velocity`) measure at this rate too.

## [0.2.0]

### Changed (breaking)
- **Unified `global()` and `bind()` into a single `propsFor()`**. It is global
  (writes to the configured root) unless the first argument is a `Node`,
  `NodeList`, or array of elements, in which case it attaches per element. Its
  disposer tears down exactly what that call started and removes the custom
  properties it wrote. The old `global` and `bind` exports are **removed**.
- **The `config` object is no longer exported.** Read or change the prefixes and
  root through `configure()`.
- **`visibility`** now writes binary, scroll-*triggered* state: `--live-visible`
  (`1`/`0`) plus `--live-has-entered` (`1`/`0`, latches on first entry and never
  resets). The continuous `--live-visible-ratio` is **removed**; use native
  `animation-timeline: view()` for continuous scroll-driven effects.
- **`range`** is now container-aware: attach it to a wrapper and it finds the inner
  `<input>`, writing `--live-value` / `--live-value-pct` on the **container** so
  the input and sibling elements (gauges, readouts) can all read the value.

### Removed (breaking)
- **Global `scroll` source** (`--live-scroll-x` / `-y` / `-progress`): superseded
  by native CSS scroll-driven animations (`animation-timeline: scroll()` /
  `view()`), which run on the compositor.
- **`el-scroll` plugin**: superseded by native `scroll(self)` / `scroll(nearest)`
  scroll timelines.

### Added
- **`propsFor()`**: one entry point for attaching sources, global or per element
  (a single element, a `NodeList`, or an array). Returns a scoped disposer.
- **`reset()`**: tears down every active binding plus the shared observers and
  listeners, for a clean slate (handy in tests and before hot reloads).
- **`unregister(key)`**: removes a source from the registry by key.
- **`SourceKey`** type export for the registry key.
- **Typed properties** via `configure({ typed: true })`: opt-in `@property`
  registration of written `--live-*` values, so they interpolate (consumers add
  `transition` / `@keyframes`) and resolve to a guaranteed `0` initial. It is
  feature-detected, idempotent, and uses `inherits: true`; sources may declare
  per-property `props` typings (default `<number>` / `0`), and consumers may set
  initial values in the same call with `configure({ typed: true, defaults: {…} })`.
- `llms.txt`: a dense, agent-oriented reference, now included in the published package.
- **`field`** is now container-aware: attach it to a wrapper and it finds the inner
  `input` / `textarea` / `select`, writing `--live-length` / `--live-empty` /
  `--live-valid` on the **container** so sibling readers (a counter, a meter, a
  status word) inherit them. Attaching directly to the field still works.
- **`media`** is now container-aware too: attach it to a wrapper and it finds the
  inner `video` / `audio`, writing `--live-progress` / `--live-current-time` /
  `--live-duration` / `--live-paused` / `--live-volume` on the **container** so
  sibling readers (a progress ring, a scrubber) inherit them.

### Fixed
- Single shared `requestAnimationFrame` loop now drives the writer and every
  sampler, instead of each source running its own.
- `scroll-velocity` idles when the page isn't scrolling, instead of polling every
  frame.
- `fps` now reports a smoothed average rather than a jittery per-frame value.
- `clock` no longer drifts: it's anchored to wall-clock time each tick.
- One shared `pointermove` listener now feeds both `pointer` and `pointer-local`.
- A failing source is isolated: it no longer takes down other sources or the loop.
- Written custom properties are removed on teardown.
- Disposers tear down only what their call started, leaving other bindings intact.

## [0.1.0]

### Added
- Initial release. Batched, diffed `requestAnimationFrame` writer.
- Core sources: `viewport`, `scroll`, `pointer`, `size`, `visibility`, `range`.
- Entries: `prop-for-that` (API), `prop-for-that/auto` (`[data-prop]` scanning),
  `prop-for-that/head` (sync FOUC-safe `--const-*`).
- 14 opt-in plugins under `prop-for-that/plugins` (`scroll-velocity`, `online`,
  `network`, `battery`, `clock`, `fps`, `visual-viewport`, `el-scroll`,
  `pointer-local`, `media`, `field`, `orientation`, `motion`, `geo`).
- Build: ESM + CJS + `.d.ts` via tsup; tree-shakeable; zero runtime deps.
