diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index f6b2a47c..dcab1f8f 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -6,47 +6,40 @@ export interface AdaptThemeOptions { /** * An initialised engine — needs resolve + contrast recheck. The exact * `isStableGlowPointNoop` capability is conditionally required when a result - * contains a stable Glow role; its absence then fails explicitly. - * `recheckContrastMulti` is optional: when metric evaluation is performed, - * it rechecks a multi-sample backdrop in ONE batched call (byte-identical to - * the per-sample loop, locked by the wasm boundary parity test); when absent, - * the controller falls back to N `recheckContrast` calls. Unchanged idle - * ticks skip metric evaluation entirely. + * contains a stable Glow role. `recheckContrastMulti` is optional and batches + * a finite explicit sample set without changing its point-wise semantics. */ colors: Pick & Partial>; theme: ThemeName; /** - * Explicit background evidence, overriding the ancestor reference estimate. - * A single hex is one solid surface; an array (or a function returning one) - * is a finite, caller-supplied sample set for a varying backdrop (gradient / - * image / video). The controller compares every supplied point and bases its - * decision on the worst returned metric; it does not infer between samples - * or observe the whole field. With one sample this is identical to plain - * single-background mode. Набор непуст и содержит только непустые строки; - * невалидный явный вход отклоняется без неявного преобразования или - * резервного значения. + * Explicit point evidence, overriding computed-CSS observation. A string array + * is a finite, caller-supplied sample set for a varying backdrop; the controller + * checks every supplied point and does not infer a Raster or Field between samples. */ background?: string | string[] | (() => string | string[]); /** Element to write the `--lab-*` variables onto. Defaults to the watched element. */ target?: HTMLElement; - /** Непрозрачная поддерживаемая база полностью прозрачной цепочки. По умолчанию `"#FFFFFF"`. */ - fallback?: string; + /** + * Caller-declared opaque page canvas for a fully translucent supported ancestor + * chain. Without it computed observation is `Unknown`; no white base is invented. + */ + canvas?: string; /** Fraction of a role's contrast surplus that may be lost before a re-solve. Default `0.2`. */ dropFraction?: number; - /** A breach must persist this many ms before re-solving (debounce). Default `120`. */ + /** A breach must persist this many ms before re-solving. Default `120`. */ sustainMs?: number; - /** Minimum ms between re-solves (dwell / rate cap). Default `250`. */ + /** Minimum ms between re-solves. Default `250`. */ dwellMs?: number; - /** Crossfade duration in ms. Default `280` (capped to a short fade under reduced motion). */ + /** Crossfade duration in ms. Default `280`. */ easeMs?: number; - /** Override reduced-motion detection (default reads `matchMedia`). */ + /** Override reduced-motion detection. */ reducedMotion?: boolean; - /** Clock injection (default `performance.now`/`Date.now`). */ + /** Clock injection. */ now?: () => number; - /** Window-like host (rAF, matchMedia). Defaults to `globalThis`. */ + /** Window-like host. */ win?: Window; - /** Injection seam for the computed style of an element (testing). */ + /** Injection seam for computed style (testing). */ getStyle?: (element: unknown) => { getPropertyValue(property: string): string }; /** Injection seam for an element's parent (testing). */ parentOf?: (element: unknown) => unknown; @@ -54,36 +47,23 @@ export interface AdaptThemeOptions { export interface AdaptController { /** - * Один шаг чтения образцов; неизменное idle-состояние пропускает метрики. - * Отказ resolver/recheck/evidence до фазы записи сохраняет - * закоммиченные логические цели и DOM-переменные. + * Read one finite sample set or one strict computed-CSS Point. A computed + * `Unknown` performs no resolver/recheck/DOM work and preserves committed state. */ tick(now?: number): void; - /** - * Мгновенно переключить тему как новое намерение, минуя гистерезис. - * Отклонённый кандидат сохраняет прежние тему, цели и DOM. Если подготовка - * реентерабельно запускает более новый `setTheme`/`tick`, новый вызов владеет - * фиксацией, а устаревший кандидат становится инертным. - */ + /** Switch theme intent immediately when Point evidence is available. */ setTheme(theme: ThemeName): void; - /** Запустить внутренний цикл `requestAnimationFrame`. */ + /** Start the internal `requestAnimationFrame` loop. */ start(): void; - /** - * Остановить внутренний цикл, не отбрасывая незавершённый переход; поздние - * `start()`/`tick()` продолжат его по текущим часам. - */ + /** Stop the internal loop without discarding an unfinished transition. */ stop(): void; - /** Канонические логические цели; во время перехода они отличаются от значений в DOM. */ + /** Канонические логические цели committed state; empty before the first supported Point commit. */ current(): Record; } /** - * Адаптирует `--lab-*` элемента к меняющейся подложке без пересчёта на каждом кадре. - * Каждый вызов `tick` читает объявленный набор образцов; при неизменных образцах - * и состоянии проверка метрик пропускается. Новый пересчёт и плавный переход - * могут начаться только после устойчивого относительного падения и выполнения - * остальных условий контроллера. Это не доказывает читаемость вне переданных - * образцов или между ними. Конфликт отклоняется до изменения DOM и контроллера, - * поэтому то же наблюдение можно повторить. + * Adapts an element to explicit finite point evidence or the strict package- + * private `Point | Unknown` computed-CSS gate. Unsupported effects, transparent + * root without `canvas`, cycles and depth exhaustion never become a fallback hex. */ export declare function adaptTheme(element: HTMLElement, options: AdaptThemeOptions): AdaptController; diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index 1535ecff..e26993eb 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -12,12 +12,8 @@ // standard-derived thresholds. Coordinate interpolation is presentation only; // it does not verify a constraint on every intermediate frame. -import { - effectiveBackground, - oklabLerp, - compileLerpPair, - lerpPairHex, -} from "./effective-bg.js"; +import { oklabLerp, compileLerpPair, lerpPairHex } from "./effective-bg.js"; +import { observePointBackground } from "./background-observation.js"; import { admitSnapshot, writeVars } from "./snapshot.js"; const CANCELLED = Symbol("adaptTheme.cancelled"); @@ -75,7 +71,7 @@ function segHex(seg, t) { * sample must be a non-empty string; invalid explicit evidence is rejected * without coercion or fallback. * @param {*} [options.target=element] element to write vars onto - * @param {string} [options.fallback="#FFFFFF"] Opaque supported base for a fully-translucent chain. + * @param {string} [options.canvas] Caller-declared opaque page canvas. * @param {number} [options.dropFraction=0.2] surplus fraction lost before re-solve * @param {number} [options.sustainMs=120] breach must persist this long * @param {number} [options.dwellMs=250] minimum between re-solves @@ -83,8 +79,8 @@ function segHex(seg, t) { * @param {boolean} [options.reducedMotion] override; default reads matchMedia * @param {() => number} [options.now] clock (default performance.now/Date.now) * @param {*} [options.win=globalThis] - * @param {(el:*)=>*} [options.getStyle] effectiveBackground seam (testing) - * @param {(el:*)=>*} [options.parentOf] effectiveBackground seam (testing) + * @param {(el:*)=>*} [options.getStyle] strict point-observation seam (testing) + * @param {(el:*)=>*} [options.parentOf] strict point-observation seam (testing) * @returns {AdaptController} */ export function adaptTheme(element, options) { @@ -125,7 +121,7 @@ export function adaptTheme(element, options) { ? stableGlowPointNoopCapability.bind(colors) : null; const target = options.target ?? element; - const fallback = options.fallback ?? "#FFFFFF"; + const canvas = options.canvas; const backgroundSource = options.background; const getStyle = options.getStyle; const parentOf = options.parentOf; @@ -202,15 +198,15 @@ export function adaptTheme(element, options) { return value; } if (backgroundSource !== undefined) return backgroundSource; - const value = effectiveBackground(element, { - fallback, + const observation = observePointBackground(element, { + canvas, getStyle, parentOf, checkpoint, checkpointToken: owner, }); checkpoint(owner); - return value; + return observation; }; // The background is a SET of samples. A solid surface is one sample; a varying @@ -219,8 +215,12 @@ export function adaptTheme(element, options) { // against EVERY sample, and we re-solve against the HARDEST sample. With one // sample this collapses to plain single-background behaviour, bit-for-bit. const readSamples = (owner) => { - const value = readBackground(owner); + let value = readBackground(owner); checkpoint(owner); + if (backgroundSource === undefined) { + if (value?.kind === "unknown") return null; + if (value?.kind === "point") value = value.hex; + } if (!Array.isArray(value)) { if (typeof value !== "string" || value.length === 0) { throw new TypeError("adaptTheme: background[0] must be a non-empty string"); @@ -800,9 +800,28 @@ export function adaptTheme(element, options) { if (!ownsOperation(owner)) return; const now = finiteTime(rawNow); const samples = readSamples(owner); - if (!ownsOperation(owner)) return; + if (!ownsOperation(owner) || samples === null) return; const key = samples.join("|"); if (!ownsOperation(owner)) return; + + // A Session that started on Unknown has no committed roles. The first Point + // is a bootstrap solve, not a glow-only or unchanged-sample fast path. + if (lastKey === null) { + const prepared = solveWorstCandidate(samples, now, theme, owner); + const candidate = withStableGlowReconciliation( + prepared.candidate, + samples, + theme, + prepared.sample0Result, + owner, + ); + if (!commitResolved(candidate, owner)) return; + lastKey = key; + easing = new Map(); + applyRolesDirect(owner); + return; + } + const hasEase = easing.size > 0; // Завершившийся ease на неизменном idle-образце не содержит fallible- @@ -993,28 +1012,30 @@ export function adaptTheme(element, options) { } }; - // Apply the initial set immediately (against the worst sample of the backdrop). + // Apply immediately only when the strict observation gate yields Point. { const owner = beginOperation(); const samples = readSamples(owner); - const nextKey = samples.join("|"); - checkpoint(owner); - const rawNow = clock(); - checkpoint(owner); - const now = finiteTime(rawNow); - const prepared = solveWorstCandidate(samples, now, theme, owner); - let candidate = withStableGlowReconciliation( - prepared.candidate, - samples, - theme, - prepared.sample0Result, - owner, - ); - if (!commitResolved(candidate, owner)) { - throw new Error("adaptTheme: initial operation lost ownership"); + if (samples !== null) { + const nextKey = samples.join("|"); + checkpoint(owner); + const rawNow = clock(); + checkpoint(owner); + const now = finiteTime(rawNow); + const prepared = solveWorstCandidate(samples, now, theme, owner); + const candidate = withStableGlowReconciliation( + prepared.candidate, + samples, + theme, + prepared.sample0Result, + owner, + ); + if (!commitResolved(candidate, owner)) { + throw new Error("adaptTheme: initial operation lost ownership"); + } + lastKey = nextKey; + applyRolesDirect(owner); } - lastKey = nextKey; - applyRolesDirect(owner); } const pendingOperations = []; @@ -1103,6 +1124,13 @@ export function adaptTheme(element, options) { const runSetThemeOwned = (next, owner) => { const samples = readSamples(owner); if (!ownsOperation(owner)) return; + if (samples === null) { + theme = next; + // Force one bootstrap solve when Point evidence returns, even if its bytes + // equal the last committed sample from the previous theme. + lastKey = null; + return; + } const nextKey = samples.join("|"); if (!ownsOperation(owner)) return; const rawNow = clock(); diff --git a/packages/colors/background-observation.js b/packages/colors/background-observation.js new file mode 100644 index 00000000..994dccbd --- /dev/null +++ b/packages/colors/background-observation.js @@ -0,0 +1,218 @@ +// Package-private C8c browser observation adapter. +// +// This adapter admits exactly one physical point from a deliberately narrow +// computed-CSS subset, or returns typed Unknown. It never invents an opaque page +// canvas, drops an unsupported layer/effect, or claims Raster/Field evidence. +// Point composition reuses the Core-owned encoded-sRGB8 source-over operation. + +import { __over } from "./pkg/labcolors.js"; +import { parseCssColor } from "./effective-bg.js"; + +const INVALID_RGB24 = 0xFFFFFFFF; +const DEFAULT_MAX_DEPTH = 64; + +/** @typedef {[number, number, number, number]} Rgba */ +/** @typedef {{kind:"point", hex:string}} PointBackgroundObservation */ +/** @typedef {{kind:"unknown", reason:string}} UnknownBackgroundObservation */ +/** @typedef {PointBackgroundObservation | UnknownBackgroundObservation} BackgroundObservation */ + +const point = (hex) => Object.freeze({ kind: "point", hex }); +const unknown = (reason) => Object.freeze({ kind: "unknown", reason }); + +function clamp255(value) { + return Math.min(255, Math.max(0, value)); +} + +function packRgb24(rgb) { + const byte = (value) => Math.round(clamp255(Number.isFinite(value) ? value : 0)); + return ((byte(rgb[0]) << 16) | (byte(rgb[1]) << 8) | byte(rgb[2])) >>> 0; +} + +function hexFromRgb24(rgb24) { + return `#${rgb24.toString(16).padStart(6, "0").toUpperCase()}`; +} + +/** + * Every translucent layer is one physical occurrence, so Core rounds every + * edge instead of preserving a fractional JS accumulator across the stack. + * + * @param {Rgba[]} layersFrontToBack + * @param {Rgba} opaqueBase + */ +function compositePointStack(layersFrontToBack, opaqueBase) { + let result = packRgb24(opaqueBase); + for (let index = layersFrontToBack.length - 1; index >= 0; index--) { + const layer = layersFrontToBack[index]; + result = __over(packRgb24(layer), layer[3], result); + if (result === INVALID_RGB24) { + throw new RangeError("background observation: Core rejected an admitted point layer"); + } + } + return hexFromRgb24(result); +} + +function admittedCanvas(value) { + if (value === undefined) return null; + const canvas = parseCssColor(value); + if (!canvas || canvas[3] !== 1) { + throw new RangeError("background observation: canvas must be an opaque supported colour"); + } + return canvas; +} + +function admittedMaxDepth(value) { + if (value === undefined) return DEFAULT_MAX_DEPTH; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError("background observation: maxDepth must be a positive safe integer"); + } + return value; +} + +function callCheckpoint(checkpoint, token) { + if (checkpoint) checkpoint(token); +} + +function styleProperty(style, property, checkpoint, token) { + if (style === null || (typeof style !== "object" && typeof style !== "function")) { + return null; + } + callCheckpoint(checkpoint, token); + const getPropertyValue = style.getPropertyValue; + callCheckpoint(checkpoint, token); + if (typeof getPropertyValue !== "function") return null; + const value = Function.prototype.call.call(getPropertyValue, style, property); + callCheckpoint(checkpoint, token); + return typeof value === "string" ? value.trim().toLowerCase() : null; +} + +const EFFECT_PROPERTIES = Object.freeze([ + ["background-image", "none", "background-image"], + ["background-blend-mode", "normal", "background-blend-mode"], + ["background-clip", "border-box", "background-clip"], + ["box-shadow", "none", "box-shadow"], + ["mix-blend-mode", "normal", "mix-blend-mode"], + ["filter", "none", "filter"], + ["backdrop-filter", "none", "backdrop-filter"], + ["-webkit-backdrop-filter", "none", "backdrop-filter"], + ["mask-image", "none", "mask-image"], + ["-webkit-mask-image", "none", "mask-image"], + ["clip-path", "none", "clip-path"], +]); + +function unsupportedEffect(style, checkpoint, token) { + for (const [property, initial, reason] of EFFECT_PROPERTIES) { + const value = styleProperty(style, property, checkpoint, token); + if (value === null) return "unreadable-computed-style"; + // Empty means this property is unavailable on the current engine. An + // unavailable property cannot contribute that effect on that engine. + if (value !== "" && value !== initial) return reason; + } + + const opacity = styleProperty(style, "opacity", checkpoint, token); + if (opacity === null) return "unreadable-computed-style"; + if (opacity !== "" && (!Number.isFinite(Number(opacity)) || Number(opacity) !== 1)) { + return "element-opacity"; + } + + const display = styleProperty(style, "display", checkpoint, token); + if (display === null) return "unreadable-computed-style"; + if (display === "none") return "display-none"; + if (display === "contents") return "display-contents"; + + const visibility = styleProperty(style, "visibility", checkpoint, token); + if (visibility === null) return "unreadable-computed-style"; + if (visibility === "hidden" || visibility === "collapse") return "visibility"; + + const contentVisibility = styleProperty(style, "content-visibility", checkpoint, token); + if (contentVisibility === null) return "unreadable-computed-style"; + if (contentVisibility !== "" && contentVisibility !== "visible") { + return "content-visibility"; + } + + return null; +} + +/** + * Observe one supported computed-CSS background point. + * + * Supported physics is intentionally narrow: uniform `background-color` layers + * with normal blending, no image/filter/backdrop/mask/clip effect, visible + * elements, and group opacity 1. A fully translucent root is Unknown unless the + * caller declares an opaque `canvas`. + * + * Finding an opaque colour stops only colour collection. The adapter still walks + * all remaining ancestors because group opacity/filter/blend on a higher ancestor + * changes the final rendered point even when backgrounds behind the opaque layer + * cannot show through. + * + * @param {*} element + * @param {object} [options] + * @param {string} [options.canvas] caller-declared opaque page canvas + * @param {(element:*)=>*} [options.getStyle] + * @param {(element:*)=>*} [options.parentOf] + * @param {number} [options.maxDepth=64] + * @param {(token:*)=>void} [options.checkpoint] + * @param {*} [options.checkpointToken] + * @returns {BackgroundObservation} + */ +export function observePointBackground(element, options = {}) { + const canvas = admittedCanvas(options.canvas); + const maxDepth = admittedMaxDepth(options.maxDepth); + const getStyle = + options.getStyle ?? + ((node) => + typeof getComputedStyle === "function" + ? getComputedStyle(node) + : { getPropertyValue: () => "" }); + const parentOf = options.parentOf ?? ((node) => node.parentElement); + const checkpoint = options.checkpoint; + if (typeof getStyle !== "function" || typeof parentOf !== "function") { + throw new TypeError("background observation: getStyle and parentOf must be functions"); + } + if (checkpoint !== undefined && typeof checkpoint !== "function") { + throw new TypeError("background observation: checkpoint must be a function"); + } + if (element === null || element === undefined) return unknown("missing-element"); + + const token = options.checkpointToken; + /** @type {Rgba[]} */ + const layers = []; + const seen = new Set(); + let node = element; + let depth = 0; + let opaqueBase = null; + + while (node !== null && node !== undefined) { + if (depth >= maxDepth) return unknown("depth-exhausted"); + if (seen.has(node)) return unknown("ancestor-cycle"); + seen.add(node); + + callCheckpoint(checkpoint, token); + const style = getStyle(node); + callCheckpoint(checkpoint, token); + const effect = unsupportedEffect(style, checkpoint, token); + if (effect !== null) return unknown(effect); + + if (opaqueBase === null) { + const css = styleProperty(style, "background-color", checkpoint, token); + if (css === null) return unknown("unreadable-computed-style"); + const colour = parseCssColor(css); + if (!colour) return unknown("unsupported-background-color"); + if (colour[3] > 0) { + if (colour[3] >= 1) opaqueBase = colour; + else layers.push(colour); + } + } + + callCheckpoint(checkpoint, token); + node = parentOf(node); + callCheckpoint(checkpoint, token); + depth++; + } + + if (opaqueBase === null) { + if (canvas === null) return unknown("transparent-root"); + opaqueBase = canvas; + } + return point(compositePointStack(layers, opaqueBase)); +} diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index d54f3d70..b63d8e80 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -1,36 +1,6 @@ -// Package-internal effective background traversal. Point composition belongs -// to the WASM Core; this shell only parses CSS, walks host state and packs RGB. -// -// `labcolors` resolves roles against a *solid* background. A real UI surface is -// often translucent (a panel at `rgba(…, .8)` over its parents) or has no -// background of its own (inheriting whatever is behind it). To resolve such a -// surface you need a background observation. This legacy helper produces a -// reference estimate for the subset it understands; it is not a browser pixel -// capture or a claim about what a viewer actually sees. -// -// This module computes it by walking the ancestor chain and **alpha-compositing** -// each element's `background-color` (front-to-back) until the stack is opaque, -// over an opaque fallback (white by default), yielding one solid reference hex -// the WASM core can consume. -// -// HONEST LIMIT: this composites solid/translucent `background-color` layers only. -// It does NOT sample `background-image`s, gradients, blurred backdrops, video, or -// content showing through — those have no single colour to read from computed -// style. For those, the caller supplies explicit finite background evidence (the -// `background` option of `watchTheme`, or declared samples). What it does cover — -// translucent panels over solid parents — is the common case and is composited -// *correctly* (true source-over alpha), not approximated. -// -// COLOUR FORMS: `parseCssColor` reads the forms this package actually meets — -// `#hex`, `rgb()/rgba()` (legacy comma and modern space/slash), `transparent`, -// and `oklch()` (the engine's OWN emission form since 0.4.0, and what a browser -// serialises `background-color` back to for an oklch-painted surface). Other -// modern forms — `lab()`, `lch()`, `color(srgb …)`, `color-mix()`, `hsl()`, -// named colours beyond `transparent` — are NOT parsed and currently become a -// dropped layer for compatibility. That is not safe evidence: pass the -// background explicitly when any unsupported layer affects the decision. - -import { __over } from "./pkg/labcolors.js"; +// Package-internal CSS colour parsing and Oklab interpolation. +// Browser background observation lives exclusively in background-observation.js; +// this module no longer walks host state or owns point composition. /** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */ @@ -373,107 +343,3 @@ export function lerpPairHex(pair, t) { ); return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]); } - -const INVALID_RGB24 = 0xFFFFFFFF; - -/** Квантует допустимый CSS parser-result в identity encoded-sRGB8. Это только - * boundary packing; source-over и его округление принадлежат Core. */ -function packRgb24(rgb) { - const byte = (value) => Math.round(clamp255(Number.isFinite(value) ? value : 0)); - return ((byte(rgb[0]) << 16) | (byte(rgb[1]) << 8) | byte(rgb[2])) >>> 0; -} - -function hexFromRgb24(rgb24) { - return `#${rgb24.toString(16).padStart(6, "0").toUpperCase()}`; -} - -/** - * Канонический point-stack: каждый слой материализуется отдельным occurrence, - * поэтому Core округляет каждый edge, а не только итог всей цепочки. - * - * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer - * @param {Rgba} opaqueBase must have alpha 1 - * @returns {string} - */ -function compositePointStack(layersFrontToBack, opaqueBase) { - let result = packRgb24(opaqueBase); - for (let i = layersFrontToBack.length - 1; i >= 0; i--) { - const layer = layersFrontToBack[i]; - result = __over(packRgb24(layer), layer[3], result); - if (result === INVALID_RGB24) { - throw new RangeError("effectiveBackground: Core rejected an admitted point layer"); - } - } - return hexFromRgb24(result); -} - -/** - * Legacy opaque reference-background estimate for the supported - * `background-color` subset. This is not a browser pixel observation. - * - * Walks from `element` upward, collecting each `background-color` layer, and - * stops at the first fully-opaque layer (which becomes the base). If the chain - * reaches the root without an opaque layer, `fallback` (default white) is used - * as an explicit compatibility assumption; it is not evidence of the canvas. - * - * Pure and injectable: pass `getStyle` and `parentOf` to test without a DOM; in - * the browser they default to `getComputedStyle` and `el.parentElement`. - * - * @param {*} element - * @param {object} [opts] - * @param {string} [opts.fallback="#FFFFFF"] opaque supported base when the chain is fully translucent - * @param {(el: *) => { getPropertyValue: (p: string) => string }} [opts.getStyle] - * @param {(el: *) => *} [opts.parentOf] - * @param {number} [opts.maxDepth=64] guard against detached/cyclic chains - * @returns {string} - */ -export function effectiveBackground(element, opts = {}) { - const fallback = opts.fallback ?? "#FFFFFF"; - const admittedFallback = parseCssColor(fallback); - if (!admittedFallback || admittedFallback[3] !== 1) { - throw new RangeError( - "effectiveBackground: fallback must be an opaque supported colour", - ); - } - const getStyle = - opts.getStyle ?? ((el) => (typeof getComputedStyle === "function" ? getComputedStyle(el) : { getPropertyValue: () => "" })); - const parentOf = opts.parentOf ?? ((el) => el.parentElement); - const maxDepth = opts.maxDepth ?? 64; - // Controller-internal cancellation seam. A host read can synchronously - // re-enter and revoke the current operation; checking between reads prevents - // the abandoned ancestor walk from invoking any later host callback. - const checkpoint = opts.checkpoint; - const checkpointToken = opts.checkpointToken; - - /** @type {Rgba[]} */ - const layers = []; - let el = element; - let depth = 0; - let base = admittedFallback; - - while (el && depth < maxDepth) { - const style = getStyle(el); - if (checkpoint) checkpoint(checkpointToken); - const getPropertyValue = style.getPropertyValue; - if (checkpoint) checkpoint(checkpointToken); - const css = Function.prototype.call.call( - getPropertyValue, - style, - "background-color", - ); - if (checkpoint) checkpoint(checkpointToken); - const c = parseCssColor(css); - if (c && c[3] > 0) { - if (c[3] >= 1) { - base = c; // first opaque layer is the base; nothing behind it shows - break; - } - layers.push(c); - } - el = parentOf(el); - if (checkpoint) checkpoint(checkpointToken); - depth++; - } - - return compositePointStack(layers, base); -} diff --git a/packages/colors/package.json b/packages/colors/package.json index 0c18c38f..3ac55708 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -48,6 +48,7 @@ "watch-theme.d.ts", "adapt-theme.js", "adapt-theme.d.ts", + "background-observation.js", "effective-bg.js", "evidence/wcag22-srgb8-v1.json", "evidence/wcag22-srgb8-q55-v1.bin", diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 74e49bf7..01c81480 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -1742,6 +1742,7 @@ test("owner loss inside the implicit backdrop walk cancels its later seams", () target: el, now: () => 1, win: {}, + canvas: "#FFFFFF", getStyle() { calls.push("style"); if (armed) { @@ -1751,7 +1752,19 @@ test("owner loss inside the implicit backdrop walk cancels its later seams", () } else { staleWalk = false; } - return { getPropertyValue: () => "transparent" }; + return { + getPropertyValue(property) { + if (property === "background-color") return "transparent"; + if (property === "background-blend-mode" || property === "mix-blend-mode") { + return "normal"; + } + if (property === "background-clip") return "border-box"; + if (property === "opacity") return "1"; + if (property === "display") return "block"; + if (property === "visibility" || property === "content-visibility") return "visible"; + return "none"; + }, + }; }, parentOf() { calls.push("parent"); diff --git a/packages/colors/test/c8c-hostile-ancestor.test.mjs b/packages/colors/test/c8c-hostile-ancestor.test.mjs new file mode 100644 index 00000000..be918222 --- /dev/null +++ b/packages/colors/test/c8c-hostile-ancestor.test.mjs @@ -0,0 +1,119 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { initSync } from "../pkg/labcolors.js"; +import { observePointBackground } from "../background-observation.js"; + +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + +const INITIALS = Object.freeze({ + "background-image": "none", + "background-blend-mode": "normal", + "background-clip": "border-box", + "box-shadow": "none", + "mix-blend-mode": "normal", + filter: "none", + "backdrop-filter": "none", + "-webkit-backdrop-filter": "none", + "mask-image": "none", + "-webkit-mask-image": "none", + "clip-path": "none", + opacity: "1", + display: "block", + visibility: "visible", + "content-visibility": "visible", +}); + +function style(backgroundColor, overrides = {}) { + const values = { ...INITIALS, "background-color": backgroundColor, ...overrides }; + return { + getPropertyValue(property) { + return values[property] ?? ""; + }, + }; +} + +function tree(entries) { + const nodes = entries.map((entry) => ({ + style: style(entry.color, entry.effects), + parent: null, + })); + for (let index = 0; index < nodes.length - 1; index++) nodes[index].parent = nodes[index + 1]; + return { + leaf: nodes[0], + getStyle: (node) => node.style, + parentOf: (node) => node.parent, + }; +} + +test("C8c checks group effects above the first opaque colour", () => { + for (const [property, value, reason] of [ + ["opacity", "0.5", "element-opacity"], + ["filter", "blur(1px)", "filter"], + ["mix-blend-mode", "multiply", "mix-blend-mode"], + ["background-clip", "padding-box", "background-clip"], + ["box-shadow", "0 0 2px black", "box-shadow"], + ["clip-path", "circle(40%)", "clip-path"], + ]) { + const host = tree([ + { color: "rgba(255, 0, 0, 0.5)" }, + { color: "rgb(0, 0, 255)" }, + { color: "rgb(0, 255, 0)", effects: { [property]: value } }, + ]); + assert.deepEqual(observePointBackground(host.leaf, host), { + kind: "unknown", + reason, + }); + } +}); + +test("C8c ignores only background colours hidden behind an opaque base", () => { + const host = tree([ + { color: "rgba(255, 0, 0, 0.5)" }, + { color: "rgb(0, 0, 255)" }, + { color: "rgb(0, 255, 0)" }, + ]); + assert.deepEqual(observePointBackground(host.leaf, host), { + kind: "point", + hex: "#800080", + }); +}); + +test("C8c does not claim a point for non-rendered elements", () => { + for (const [property, value, reason] of [ + ["display", "none", "display-none"], + ["display", "contents", "display-contents"], + ["visibility", "hidden", "visibility"], + ["visibility", "collapse", "visibility"], + ["content-visibility", "hidden", "content-visibility"], + ["content-visibility", "auto", "content-visibility"], + ]) { + const host = tree([{ color: "rgb(1, 2, 3)", effects: { [property]: value } }]); + assert.deepEqual(observePointBackground(host.leaf, host), { + kind: "unknown", + reason, + }); + } +}); + +test("C8c validates host seams before the first read", () => { + const host = tree([{ color: "rgb(1, 2, 3)" }]); + let reads = 0; + const getStyle = (node) => { + reads++; + return node.style; + }; + + assert.throws( + () => observePointBackground(host.leaf, { getStyle, parentOf: 1 }), + /getStyle and parentOf must be functions/u, + ); + assert.throws( + () => observePointBackground(host.leaf, { getStyle, parentOf: host.parentOf, checkpoint: 1 }), + /checkpoint must be a function/u, + ); + assert.equal(reads, 0); +}); diff --git a/packages/colors/test/c8c-point-unknown.test.mjs b/packages/colors/test/c8c-point-unknown.test.mjs new file mode 100644 index 00000000..ed02cea4 --- /dev/null +++ b/packages/colors/test/c8c-point-unknown.test.mjs @@ -0,0 +1,351 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { initSync } from "../pkg/labcolors.js"; +import { observePointBackground } from "../background-observation.js"; +import { watchTheme } from "../watch-theme.js"; +import { adaptTheme } from "../adapt-theme.js"; + +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + +const INITIALS = Object.freeze({ + "background-image": "none", + "background-blend-mode": "normal", + "background-clip": "border-box", + "box-shadow": "none", + "mix-blend-mode": "normal", + filter: "none", + "backdrop-filter": "none", + "-webkit-backdrop-filter": "none", + "mask-image": "none", + "-webkit-mask-image": "none", + opacity: "1", +}); + +function style(backgroundColor, overrides = {}) { + const values = { ...INITIALS, "background-color": backgroundColor, ...overrides }; + return { + getPropertyValue(property) { + return values[property] ?? ""; + }, + }; +} + +function tree(entries) { + const nodes = entries.map((entry) => ({ + style: style(entry.color, entry.effects), + parent: null, + })); + for (let index = 0; index < nodes.length - 1; index++) nodes[index].parent = nodes[index + 1]; + return { + leaf: nodes[0], + nodes, + getStyle: (node) => node.style, + parentOf: (node) => node.parent, + }; +} + +function target() { + const props = new Map(); + const mutations = []; + return { + props, + mutations, + style: { + get length() { + return props.size; + }, + item(index) { + return [...props.keys()][index] ?? null; + }, + setProperty(name, value) { + mutations.push(["set", name, value]); + props.set(name, value); + }, + removeProperty(name) { + mutations.push(["remove", name]); + props.delete(name); + }, + }, + }; +} + +function watchEngine() { + const calls = []; + return { + calls, + resolveTheme(background, theme) { + calls.push({ background, theme }); + return { + theme, + background, + vars: { "--lab-x": background }, + roles: {}, + }; + }, + }; +} + +function adaptiveEngine() { + const calls = []; + let rechecks = 0; + return { + calls, + rechecks: () => rechecks, + resolveTheme(background, theme) { + calls.push({ background, theme }); + return { + vars: { "--lab-label-primary": "#123456" }, + roles: { + "label-primary": { + kind: "color", + cssVar: "--lab-label-primary", + hex: "#123456", + lc: 60, + }, + }, + }; + }, + recheckContrast() { + rechecks++; + return [60, 10]; + }, + }; +} + +test("C8c admits one supported opaque point without a canvas assumption", () => { + const host = tree([ + { color: "rgba(255, 0, 0, 0.5)" }, + { color: "rgb(0, 0, 255)" }, + ]); + assert.deepEqual( + observePointBackground(host.leaf, host), + { kind: "point", hex: "#800080" }, + ); +}); + +test("C8c rounds every translucent occurrence through Core", () => { + const host = tree([ + { color: "rgba(0, 0, 0, 0.5)" }, + { color: "rgba(1, 0, 0, 0.5)" }, + { color: "rgb(0, 0, 0)" }, + ]); + assert.deepEqual( + observePointBackground(host.leaf, host), + { kind: "point", hex: "#010000" }, + ); +}); + +test("C8c transparent root is Unknown unless an opaque canvas is declared", () => { + const host = tree([ + { color: "transparent" }, + { color: "rgba(0, 0, 0, 0.5)" }, + ]); + assert.deepEqual(observePointBackground(host.leaf, host), { + kind: "unknown", + reason: "transparent-root", + }); + assert.deepEqual( + observePointBackground(host.leaf, { ...host, canvas: "#FFFFFF" }), + { kind: "point", hex: "#808080" }, + ); +}); + +test("C8c invalid declared canvas fails before any host read", () => { + let reads = 0; + const host = tree([{ color: "transparent" }]); + assert.throws( + () => + observePointBackground(host.leaf, { + ...host, + canvas: "rgba(255, 255, 255, 0.5)", + getStyle(node) { + reads++; + return node.style; + }, + }), + /canvas must be an opaque supported colour/u, + ); + assert.equal(reads, 0); + for (const canvas of ["#FZFFFF", "rebeccapurple", "oklch(50% 1e308 0)"]) { + assert.throws( + () => observePointBackground(host.leaf, { ...host, canvas }), + /canvas must be an opaque supported colour/u, + canvas, + ); + } +}); + +test("C8c unsupported colour and effects are typed Unknown, never dropped layers", () => { + for (const [property, value, reason] of [ + ["background-image", "linear-gradient(red, blue)", "background-image"], + ["background-blend-mode", "multiply", "background-blend-mode"], + ["background-clip", "padding-box", "background-clip"], + ["box-shadow", "inset 0 0 2px black", "box-shadow"], + ["mix-blend-mode", "screen", "mix-blend-mode"], + ["filter", "blur(2px)", "filter"], + ["backdrop-filter", "blur(2px)", "backdrop-filter"], + ["mask-image", "linear-gradient(black, transparent)", "mask-image"], + ["opacity", "0.5", "element-opacity"], + ]) { + const host = tree([ + { color: "rgba(255, 0, 0, 0.5)", effects: { [property]: value } }, + { color: "rgb(0, 0, 0)" }, + ]); + assert.deepEqual(observePointBackground(host.leaf, host), { + kind: "unknown", + reason, + }); + } + + const unsupportedColour = tree([ + { color: "color(display-p3 1 0 0)" }, + { color: "rgb(0, 0, 0)" }, + ]); + assert.deepEqual(observePointBackground(unsupportedColour.leaf, unsupportedColour), { + kind: "unknown", + reason: "unsupported-background-color", + }); +}); + +test("C8c cycle and depth exhaustion are distinct Unknown outcomes", () => { + const cycle = tree([ + { color: "transparent" }, + { color: "transparent" }, + ]); + cycle.nodes[1].parent = cycle.nodes[0]; + assert.deepEqual(observePointBackground(cycle.leaf, cycle), { + kind: "unknown", + reason: "ancestor-cycle", + }); + + const deep = tree([ + { color: "transparent" }, + { color: "transparent" }, + { color: "rgb(0, 0, 0)" }, + ]); + assert.deepEqual(observePointBackground(deep.leaf, { ...deep, maxDepth: 2 }), { + kind: "unknown", + reason: "depth-exhausted", + }); + assert.throws( + () => observePointBackground(deep.leaf, { ...deep, maxDepth: 0 }), + /maxDepth must be a positive safe integer/u, + ); +}); + +test("C8c output algebra is closed to Point or Unknown", () => { + const supported = tree([{ color: "rgb(1, 2, 3)" }]); + const cases = [ + observePointBackground(supported.leaf, supported), + observePointBackground(null), + ]; + for (const result of cases) { + assert.ok(result.kind === "point" || result.kind === "unknown"); + assert.equal(Object.hasOwn(result, "raster"), false); + assert.equal(Object.hasOwn(result, "field"), false); + } +}); + +test("watchTheme performs no resolver or DOM work on Unknown and recovers on Point", () => { + const host = tree([{ color: "transparent" }]); + const output = target(); + const colors = watchEngine(); + const controller = watchTheme(host.leaf, { + colors, + theme: "light", + target: output, + observe: false, + getStyle: host.getStyle, + parentOf: host.parentOf, + }); + + assert.equal(colors.calls.length, 0); + assert.equal(output.mutations.length, 0); + assert.equal(controller.background(), null); + + host.nodes[0].style = style("rgb(255, 255, 255)"); + controller.refresh(); + assert.deepEqual(colors.calls, [{ background: "#FFFFFF", theme: "light" }]); + assert.equal(output.props.get("--lab-x"), "#FFFFFF"); + assert.equal(controller.background(), "#FFFFFF"); + + host.nodes[0].style = style("rgb(255, 255, 255)", { + "background-image": "linear-gradient(red, blue)", + }); + const mutations = output.mutations.length; + controller.refresh(); + assert.equal(colors.calls.length, 1); + assert.equal(output.mutations.length, mutations); + assert.equal(controller.background(), "#FFFFFF"); +}); + +test("watchTheme preserves theme intent across Unknown", () => { + const host = tree([{ color: "transparent" }]); + const colors = watchEngine(); + const controller = watchTheme(host.leaf, { + colors, + theme: "light", + target: target(), + observe: false, + getStyle: host.getStyle, + parentOf: host.parentOf, + }); + controller.setTheme("dark"); + assert.equal(colors.calls.length, 0); + + host.nodes[0].style = style("rgb(0, 0, 0)"); + controller.refresh(); + assert.deepEqual(colors.calls, [{ background: "#000000", theme: "dark" }]); +}); + +test("adaptTheme bootstraps only when Unknown becomes Point", () => { + const host = tree([{ color: "transparent" }]); + const output = target(); + const colors = adaptiveEngine(); + const controller = adaptTheme(host.leaf, { + colors, + theme: "light", + target: output, + getStyle: host.getStyle, + parentOf: host.parentOf, + now: () => 0, + reducedMotion: true, + }); + + assert.equal(colors.calls.length, 0); + assert.equal(colors.rechecks(), 0); + assert.deepEqual(controller.current(), {}); + assert.equal(output.mutations.length, 0); + + host.nodes[0].style = style("rgb(255, 255, 255)"); + controller.tick(1); + assert.deepEqual(colors.calls, [{ background: "#FFFFFF", theme: "light" }]); + assert.equal(output.props.get("--lab-label-primary"), "#123456"); +}); + +test("adaptTheme preserves setTheme intent while observation is Unknown", () => { + const host = tree([{ color: "rgb(255, 255, 255)" }]); + const colors = adaptiveEngine(); + const controller = adaptTheme(host.leaf, { + colors, + theme: "light", + target: target(), + getStyle: host.getStyle, + parentOf: host.parentOf, + now: () => 0, + reducedMotion: true, + }); + assert.equal(colors.calls.length, 1); + + host.nodes[0].style = style("transparent"); + controller.setTheme("dark"); + assert.equal(colors.calls.length, 1); + + host.nodes[0].style = style("rgb(255, 255, 255)"); + controller.tick(1); + assert.deepEqual(colors.calls.at(-1), { background: "#FFFFFF", theme: "dark" }); +}); diff --git a/packages/colors/test/oklch-parse.test.mjs b/packages/colors/test/oklch-parse.test.mjs index 9cc451f1..b839e6f9 100644 --- a/packages/colors/test/oklch-parse.test.mjs +++ b/packages/colors/test/oklch-parse.test.mjs @@ -1,20 +1,21 @@ -// Class-lock: the walker must parse EXACTLY the colour form the engine emits. +// Class-lock: the point observation adapter must parse EXACTLY the colour form the engine emits. // // Since @labpics/colors 0.4.0 the core emits every CSS variable as // `oklch(L% C H)` / `oklch(L% C H / A)` (crates/labcolors-core/src/spaces/ // oklch.rs::oklch_css_from_hex). Per CSS Color 4, a browser then serialises the // *computed* `background-color` of an oklch()-painted surface back in OKLCH form // (Chrome ≥ M111 yields `oklch( )`, optionally ` / `). If -// `parseCssColor` cannot read that form, `effectiveBackground` silently drops +// `parseCssColor` cannot read that form, the observation adapter would reject // the layer → a wrong effective background on the very surfaces the package -// paints. This suite closes that class: emission and walker must agree. +// paints. This suite closes that class: emission and point observation must agree. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { initSync } from "../pkg/labcolors.js"; -import { parseCssColor, effectiveBackground } from "../effective-bg.js"; +import { parseCssColor } from "../effective-bg.js"; +import { observePointBackground } from "../background-observation.js"; initSync({ module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), @@ -79,11 +80,14 @@ test("self-consistency lock: parseCssColor decodes EXACTLY what the engine emits }); test("the reported bug: an opaque oklch base is a real layer, not silently dropped", () => { - // Pre-fix, parseCssColor(oklch(...)) → null, so effectiveBackground treated the - // package's own painted surface as "no layer" and fell through to white. + // Pre-fix, parseCssColor(oklch(...)) → null, so the browser adapter could not + // admit the package's own painted surface as a physical point. const opaque = "oklch(64.04613% 0.193058 259.892)"; // #3E87FF, opaque const tree = fakeTree([opaque]); - assert.equal(effectiveBackground(tree.leaf, tree), "#3E87FF"); + assert.deepEqual(observePointBackground(tree.leaf, tree), { + kind: "point", + hex: "#3E87FF", + }); }); test("Chrome computed form: L as a 0..1 number parses, and equals the percentage form", () => { @@ -97,14 +101,17 @@ test("Chrome computed form: L as a 0..1 number parses, and equals the percentage assert.deepEqual(withAlpha, [...parsed.slice(0, 3), 0.8]); }); -test("effectiveBackground composites oklch layers (translucent over opaque)", () => { +test("point observation composites oklch layers (translucent over opaque)", () => { // A translucent white oklch panel over an opaque near-black oklch base — the // exact self-composed case the package produces. The known byte arithmetic // is `26 + .5 × (255 - 26) = 140.5`, round-half-up → 141 (`#8D8D8D`). const leaf = "oklch(100.00000% 0.000000 89.876 / 0.5)"; // #FFFFFF @ 0.5 const base = "oklch(21.77865% 0.000000 89.876)"; // #1A1A1A opaque const tree = fakeTree([leaf, base]); - assert.equal(effectiveBackground(tree.leaf, tree), "#8D8D8D"); + assert.deepEqual(observePointBackground(tree.leaf, tree), { + kind: "point", + hex: "#8D8D8D", + }); }); test("component forms: none = 0, chroma as a percentage (100% = 0.4), deg suffix on hue", () => { @@ -181,14 +188,26 @@ test("garbage inside oklch(...) yields null, never throws", () => { for (const s of bad) assert.equal(parseCssColor(s), null, `${s} must be null`); }); -// A leaf→root element tree for effectiveBackground: each node carries a -// background-color string. index 0 = leaf. Returns { leaf, getStyle, parentOf }. +// A leaf→root element tree for the strict point observation adapter. Each +// node carries one uniform background-color and otherwise default computed CSS. function fakeTree(chain) { const nodes = chain.map((bg) => ({ bg, parent: null })); for (let i = 0; i < nodes.length - 1; i++) nodes[i].parent = nodes[i + 1]; return { leaf: nodes[0], - getStyle: (el) => ({ getPropertyValue: () => el.bg }), + getStyle: (el) => ({ + getPropertyValue(property) { + if (property === "background-color") return el.bg; + if (property === "background-blend-mode" || property === "mix-blend-mode") { + return "normal"; + } + if (property === "background-clip") return "border-box"; + if (property === "opacity") return "1"; + if (property === "display") return "block"; + if (property === "visibility" || property === "content-visibility") return "visible"; + return "none"; + }, + }), parentOf: (el) => el.parent, }; } diff --git a/packages/colors/test/public-api-cleanup.test.mjs b/packages/colors/test/public-api-cleanup.test.mjs index a6aa2614..19b7340b 100644 --- a/packages/colors/test/public-api-cleanup.test.mjs +++ b/packages/colors/test/public-api-cleanup.test.mjs @@ -13,13 +13,16 @@ test("effective-background math stays internal to the browser shell", () => { const rootRuntime = read("packages", "colors", "index.js"); const rootTypes = read("packages", "colors", "index.d.ts"); const backdropRuntime = read("packages", "colors", "effective-bg.js"); + const observationRuntime = read("packages", "colors", "background-observation.js"); const releaseVerifier = read("scripts", "verify-package-release.mjs"); assert.equal(manifest.exports["./effective-bg"], undefined); + assert.equal(manifest.exports["./background-observation"], undefined); assert.doesNotMatch(releaseVerifier, /from "@labpics\/colors\/effective-bg"/u); assert.match(releaseVerifier, /import\("@labpics\/colors\/effective-bg"\)/u); for (const name of [ "effectiveBackground", + "observePointBackground", "parseCssColor", "compositeOver", "compositeStackToHex", @@ -32,10 +35,16 @@ test("effective-background math stays internal to the browser shell", () => { } assert.ok( manifest.files.includes("effective-bg.js"), - "watch/adapt still need the internal estimate until occurrence cutover", + "controllers still need package-private parsing and interpolation helpers", + ); + assert.ok( + manifest.files.includes("background-observation.js"), + "controllers need the package-private Point | Unknown bridge in the tarball", ); assert.equal(manifest.exports["./pkg/labcolors.js"], undefined); - assert.match(backdropRuntime, /__over/u); + assert.doesNotMatch(backdropRuntime, /__over|effectiveBackground/u); + assert.match(observationRuntime, /__over/u); + assert.match(observationRuntime, /export function observePointBackground/u); assert.doesNotMatch( backdropRuntime, /export function compositeOver|function compositeOver|compositeStackToHex/u, diff --git a/packages/colors/test/runtime.test.mjs b/packages/colors/test/runtime.test.mjs index c36a1117..03945fae 100644 --- a/packages/colors/test/runtime.test.mjs +++ b/packages/colors/test/runtime.test.mjs @@ -10,12 +10,7 @@ import { readFileSync } from "node:fs"; import { runInNewContext } from "node:vm"; import { initSync } from "../pkg/labcolors.js"; -import { - parseCssColor, - toHex, - effectiveBackground, - oklabLerp, -} from "../effective-bg.js"; +import { parseCssColor, toHex, oklabLerp } from "../effective-bg.js"; import { applyTheme } from "../apply-theme.js"; import { watchTheme } from "../watch-theme.js"; @@ -373,89 +368,6 @@ test("oklabLerp falls back to the valid endpoint on unparseable input", () => { assert.equal(oklabLerp("#123456", "garbage", 0.7), "#123456"); }); -test("effective background rounds every declared point occurrence", () => { - const { leaf, getStyle, parentOf } = fakeTree([ - "rgba(0, 0, 0, 0.5)", - "rgba(1, 0, 0, 0.5)", - "rgb(0, 0, 0)", - ]); - // Point-граф материализует нижний occurrence в байт 1, затем верхний снова - // в байт 1. Старый JS-stack сохранял дробный 0.5 между рёбрами и округлял - // только общий итог 0.25 до нуля — это была другая физическая программа. - assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#010000"); -}); - -test("effective background preserves front-to-back layer order", () => { - const { leaf, getStyle, parentOf } = fakeTree([ - "rgba(255, 0, 0, 0.5)", - "rgba(0, 0, 255, 0.5)", - "rgb(0, 0, 0)", - ]); - assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#800040"); -}); - -test("effective background quantises fractional CSS channels by nearest byte", () => { - const { leaf, getStyle, parentOf } = fakeTree(["rgb(0.5 127.5 254.5)"]); - assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#0180FF"); -}); - -test("effective background never reinterprets a translucent fallback as opaque", () => { - const { leaf, getStyle, parentOf } = fakeTree(["rgba(0, 0, 0, 0.5)"]); - - assert.throws( - () => effectiveBackground(leaf, { - fallback: "rgba(255, 0, 0, 0.5)", - getStyle, - parentOf, - }), - /fallback must be an opaque supported colour/u, - ); - for (const fallback of ["#FZFFFF", "oklch(50% 1e308 0)", "oklch(50% 0.1 1e308)"]) { - assert.throws( - () => effectiveBackground(leaf, { - fallback, - getStyle, - parentOf, - }), - /fallback must be an opaque supported colour/u, - fallback, - ); - } -}); - -// A tiny fake element tree for effectiveBackground: each node carries a -// background-color string and a parent. The injected getStyle/parentOf read it. -function fakeTree(chain) { - // chain: array of bg strings, index 0 = leaf, last = root. - const nodes = chain.map((bg) => ({ bg, parent: null })); - for (let i = 0; i < nodes.length - 1; i++) nodes[i].parent = nodes[i + 1]; - const getStyle = (el) => ({ getPropertyValue: () => el.bg }); - const parentOf = (el) => el.parent; - return { leaf: nodes[0], getStyle, parentOf }; -} - -test("effectiveBackground stops at the first opaque ancestor", () => { - const { leaf, getStyle, parentOf } = fakeTree([ - "rgba(0, 0, 0, 0)", // leaf transparent - "rgba(255, 255, 255, 0.5)", // translucent panel - "rgb(0, 0, 0)", // opaque black base - "rgb(255, 0, 0)", // (never reached — behind the opaque) - ]); - // 50% white over black → #808080; the red below the opaque black is ignored. - assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#808080"); -}); - -test("effectiveBackground falls back to white when the chain is fully translucent", () => { - const { leaf, getStyle, parentOf } = fakeTree(["transparent", "rgba(0,0,0,0)"]); - assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#FFFFFF"); - const tinted = fakeTree(["rgba(0, 0, 0, 0.5)"]); - // 50% black over the default white fallback → #808080. - assert.equal( - effectiveBackground(tinted.leaf, { getStyle: tinted.getStyle, parentOf: tinted.parentOf }), - "#808080", - ); -}); - // A fake LabColors engine + element for watchTheme. function fakeEngine() { const calls = []; @@ -489,7 +401,9 @@ test("watchTheme applies on creation and re-resolves only when the bg changes", colors, theme: "light", observe: false, // no DOM observer in node - getStyle: (e) => ({ getPropertyValue: () => e.bg }), + getStyle: (e) => ({ + getPropertyValue: (property) => (property === "background-color" ? e.bg : ""), + }), parentOf: () => null, }); @@ -755,6 +669,7 @@ test("watchTheme acquires no observer when its initial resolve fails", () => { }, }, theme: "light", + background: "#FFFFFF", win, }), /initial resolve failed/u, @@ -837,7 +752,12 @@ test("watchTheme returns the observer owner before reporting an observe failure" }, }; - const controller = watchTheme(element, { colors: fakeEngine(), theme: "light", win }); + const controller = watchTheme(element, { + colors: fakeEngine(), + theme: "light", + background: "#FFFFFF", + win, + }); assert.deepEqual(reported, [], "reporting must happen after the controller is returned"); assert.equal(disconnects, 1, "a partially-acquired observer must be released"); assert.equal(writes, 0, "observe failure must precede the initial DOM commit"); @@ -877,6 +797,7 @@ test("watchTheme returns the observer owner and cancels queued work when initial const controller = watchTheme(element, { colors, theme: "light", + background: "#FFFFFF", onError: (error) => errors.push(error), win, }); @@ -917,6 +838,7 @@ test("watchTheme retains an acquired observer when apply, disconnect and reporti const controller = watchTheme(element, { colors: fakeEngine(), theme: "light", + background: "#FFFFFF", onError(error) { errors.push(error); throw reportingFailure; @@ -975,6 +897,7 @@ test("watchTheme never entrusts startup error reporting to the injected schedule controller = watchTheme(element, { colors: fakeEngine(), theme: "light", + background: "#FFFFFF", onError: (error) => reports.push({ error, ownerReachable: controller !== undefined }), win: { MutationObserver: RetriableObserver, @@ -1292,7 +1215,9 @@ test("watchTheme: stop() cancels a refresh already scheduled by a mutation", asy theme: "light", win, onError: (error) => errors.push(error), - getStyle: (e) => ({ getPropertyValue: () => e.bg }), + getStyle: (e) => ({ + getPropertyValue: (property) => (property === "background-color" ? e.bg : ""), + }), parentOf: () => null, }); assert.equal(colors.calls.length, 1); // applied on creation @@ -1865,6 +1790,7 @@ test("watchTheme: owner loss inside the implicit backdrop walk cancels later sea theme: "initial", target: element, observe: false, + canvas: "#FFFFFF", getStyle() { calls.push("style"); if (armed) { @@ -1874,7 +1800,19 @@ test("watchTheme: owner loss inside the implicit backdrop walk cancels later sea } else { staleWalk = false; } - return { getPropertyValue: () => "transparent" }; + return { + getPropertyValue(property) { + if (property === "background-color") return "transparent"; + if (property === "background-blend-mode" || property === "mix-blend-mode") { + return "normal"; + } + if (property === "background-clip") return "border-box"; + if (property === "opacity") return "1"; + if (property === "display") return "block"; + if (property === "visibility" || property === "content-visibility") return "visible"; + return "none"; + }, + }; }, parentOf() { calls.push("parent"); diff --git a/packages/colors/watch-theme.d.ts b/packages/colors/watch-theme.d.ts index e65a0c5e..f62fcbf0 100644 --- a/packages/colors/watch-theme.d.ts +++ b/packages/colors/watch-theme.d.ts @@ -8,23 +8,26 @@ export interface WatchThemeOptions { /** Theme name. */ theme: ThemeName; /** - * Explicit reference background, overriding the ancestor estimate. A hex - * sampled from image/gradient/blur content remains one declared point, not an - * observation of the whole field. Явное значение обязано быть непустой - * строкой; невалидное значение не подменяется fallback-оценкой. + * Explicit point background evidence, overriding computed-CSS observation. A + * hex sampled from image/gradient/blur content remains one declared point, not + * an observation of the whole field. The value must be a non-empty string; + * invalid explicit evidence is rejected instead of being reinterpreted. */ background?: string | (() => string); /** Element to write the `--lab-*` variables onto. Defaults to the watched element. */ target?: HTMLElement; - /** Непрозрачная поддерживаемая база полностью прозрачной цепочки. По умолчанию `"#FFFFFF"`. */ - fallback?: string; + /** + * Caller-declared opaque page canvas used only when the supported ancestor + * chain is fully translucent. Without it that state is `Unknown`; no white + * canvas is invented. The value must be an opaque supported CSS colour. + */ + canvas?: string; /** Auto-refresh on `style`/`class` attribute changes in the observed subtree. Default `true`. */ observe?: boolean; /** - * Получает отказы observer-обновлений и startup-отказы после захвата - * observer. Явные `refresh()` и `setTheme()` синхронны и бросают вызывающему. - * Без обработчика host сообщает об исключении через `reportError`/своё - * error-событие. + * Receives failures from observer updates and startup after observer + * acquisition. A typed computed-CSS `Unknown` is not an error and causes no + * resolve or DOM write. Explicit `refresh()`/`setTheme()` failures still throw. */ onError?: (error: unknown) => void; /** Mutation-observer root. Defaults to the document element. */ @@ -39,31 +42,24 @@ export interface WatchThemeOptions { export interface WatchController { /** - * Re-resolve and re-apply if the background/reference input (or theme) changed; - * `force` re-applies unconditionally. Returns the now-applied result, or the - * cached one when nothing changed. Returns `null` only when observer - * acquisition preceded a failed startup, so no snapshot exists yet. + * Re-resolve and re-apply when a supported Point observation or theme changes. + * `force` re-applies a known Point unconditionally. On computed-CSS `Unknown`, + * no engine/DOM update occurs and the last committed result is returned. */ refresh(force?: boolean): ResolvedTheme | null; - /** Switch theme and re-apply; a rejected candidate keeps the committed theme. */ + /** Switch theme intent; a rejected candidate keeps the committed theme/output. */ setTheme(theme: ThemeName): void; - /** The background/reference hex last committed, or `null` before any commit. */ + /** The point background last committed, or `null` before any successful commit. */ background(): string | null; /** Disconnect observers and stop watching. */ stop(): void; } /** - * Согласует `--lab-*` элемента с явной подложкой или поддерживаемой оценкой по - * цепочке предков. - * - * Изменения атрибутов `style`/`class` в наблюдаемом поддереве планируют refresh; - * непрерывные входы обновляются вызовом `refresh()` из цикла - * `requestAnimationFrame`. Конфликт отклоняется до изменения DOM или состояния - * контроллера, поэтому то же наблюдение можно повторить. Изменения пикселей и - * раскладки не отслеживаются. До захвата observer startup-ошибка синхронна; - * после захвата функция сначала возвращает владельца ресурса, затем сообщает - * ошибку через `onError`/host error channel. + * Aligns an element's `--lab-*` variables with explicit point evidence or the + * package-private strict computed-CSS `Point | Unknown` observation gate. + * Unsupported colours/effects, a translucent root without `canvas`, cycles and + * depth exhaustion never become an invented hex and never call the resolver. */ export declare function watchTheme( element: HTMLElement, diff --git a/packages/colors/watch-theme.js b/packages/colors/watch-theme.js index 2c45466a..0feb7b3d 100644 --- a/packages/colors/watch-theme.js +++ b/packages/colors/watch-theme.js @@ -13,11 +13,11 @@ // inside its own `requestAnimationFrame` loop. `refresh()` re-resolves only // when the supplied/reference background string changes. // -// The fallback estimate alpha-composites the supported ancestor -// `background-color` chain (`effective-bg.js`). For images/gradients/blur, pass -// an explicit reference hex; one sample does not represent the whole field. +// Omitted input passes through the strict package-private Point | Unknown +// observation gate. Unsupported effects or missing canvas evidence never become +// a fallback hex and never invoke the resolver. -import { effectiveBackground } from "./effective-bg.js"; +import { observePointBackground } from "./background-observation.js"; import { admitSnapshot, writeVars } from "./snapshot.js"; const CANCELLED = Symbol("watchTheme.cancelled"); @@ -74,7 +74,7 @@ const deferOutsideInjectedHost = (callback) => { * When supplied, it must be a non-empty string; invalid explicit evidence is * rejected instead of being reinterpreted as the omitted-input fallback. * @param {*} [options.target=element] Element to write the variables onto. - * @param {string} [options.fallback="#FFFFFF"] Opaque supported base for a fully-translucent chain. + * @param {string} [options.canvas] Caller-declared opaque page canvas. * @param {boolean} [options.observe=true] Auto-refresh on `style`/`class` * attribute changes in the observed subtree. * @param {(error: unknown) => void} [options.onError] Receives failures from @@ -82,8 +82,8 @@ const deferOutsideInjectedHost = (callback) => { * `refresh`/`setTheme` по-прежнему бросают. * @param {*} [options.root] Mutation-observer root (default: the document element). * @param {*} [options.win=globalThis] Window-like host (for MutationObserver). - * @param {(el:*)=>*} [options.getStyle] Injection seam for `effectiveBackground`. - * @param {(el:*)=>*} [options.parentOf] Injection seam for `effectiveBackground`. + * @param {(el:*)=>*} [options.getStyle] Injection seam for strict point observation. + * @param {(el:*)=>*} [options.parentOf] Injection seam for strict point observation. * @returns {WatchController} */ export function watchTheme(element, options) { @@ -101,7 +101,7 @@ export function watchTheme(element, options) { } const target = options.target ?? element; - const fallback = options.fallback ?? "#FFFFFF"; + const canvas = options.canvas; const backgroundSource = options.background; const getStyle = options.getStyle; const parentOf = options.parentOf; @@ -175,32 +175,38 @@ export function watchTheme(element, options) { return value; }; - const readBackground = (owner) => { + const readObservation = (owner) => { if (typeof backgroundSource === "function") { const value = backgroundSource(); checkpoint(owner); - return requireBackground(value); + return { kind: "point", hex: requireBackground(value) }; } - if (backgroundSource !== undefined) return requireBackground(backgroundSource); - const value = effectiveBackground(element, { - fallback, + if (backgroundSource !== undefined) { + return { kind: "point", hex: requireBackground(backgroundSource) }; + } + const observation = observePointBackground(element, { + canvas, getStyle, parentOf, checkpoint, checkpointToken: owner, }); checkpoint(owner); - return value; + return observation; }; const prepareFor = (candidateTheme, force, owner) => { - const bg = readBackground(owner); + const observation = readObservation(owner); checkpoint(owner); + if (observation.kind === "unknown") { + return { kind: "unknown", candidateTheme, reason: observation.reason }; + } + const bg = observation.hex; if (!force && bg === lastBg && candidateTheme === lastTheme) { // Прошлое CSSOM-исключение могло оставить inline-стиль записанным // частично. Переиспользуем закоммиченный физический снимок: чинить // императивную оболочку резолвером не нужно. - return dirty ? { bg, candidateTheme, result: lastResult } : null; + return dirty ? { kind: "point", bg, candidateTheme, result: lastResult } : null; } // Допуск принадлежит prepare-фазе: конфликт ещё не затронул DOM или // controller state, поэтому то же observation можно повторить. @@ -208,7 +214,7 @@ export function watchTheme(element, options) { checkpoint(owner); const result = admitSnapshot(raw, "watchTheme", checkpoint, owner); checkpoint(owner); - return { bg, candidateTheme, result }; + return { kind: "point", bg, candidateTheme, result }; }; const commitPrepared = ({ bg, candidateTheme, result }, owner) => { @@ -258,7 +264,12 @@ export function watchTheme(element, options) { // кандидат устарел — вернуть закоммиченное состояние без записи. return lastResult; } - return prepared === null ? lastResult : commitPrepared(prepared, gen); + if (prepared === null) return lastResult; + if (prepared.kind === "unknown") { + theme = candidateTheme; + return lastResult; + } + return commitPrepared(prepared, gen); }; const runStop = () => { @@ -468,7 +479,7 @@ export function watchTheme(element, options) { }); } } - if (!stopped && initialGen === generation) { + if (!stopped && initialGen === generation && initial.kind === "point") { commitPrepared(initial, initialGen); } } catch (error) {