Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 26 additions & 46 deletions packages/colors/adapt-theme.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,84 +6,64 @@ 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<LabColors, "resolveTheme" | "recheckContrast"> &
Partial<Pick<LabColors, "recheckContrastMulti" | "isStableGlowPointNoop">>;
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;
}

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<string, string>;
}

/**
* Адаптирует `--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;
94 changes: 61 additions & 33 deletions packages/colors/adapt-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -75,16 +71,16 @@ 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
* @param {number} [options.easeMs=280] crossfade duration
* @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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand Down Expand Up @@ -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-
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading