Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 5 additions & 9 deletions crates/labcolors-core/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@
//!
//! # Почему FNV-1a
//!
//! Нужен маленький, детерминированный, кросс-рантаймовый (JS↔Rust) хеш без
//! зависимостей, дающий побайтово идентичный результат в обоих рантаймах.
//! FNV-1a — это ~10 строк целочисленной арифметики без таблиц, что делает
//! JS-зеркало тривиально верифицируемым против этой реализации
//! (`packages/colors/fnv1a.js`, дифференциальный тест
//! `tests/fnv1a_differential.rs`).
//! Нужен маленький детерминированный хеш без зависимостей. FNV-1a — это
//! несколько строк целочисленной арифметики без таблиц; опубликованные векторы
//! и воспроизводимые hostile-входы проверяются в `tests/fnv1a_vectors.rs`.
//!
//! # Провенанс констант
//!
Expand All @@ -31,9 +28,8 @@ const FNV1A_32_PRIME: u32 = 16777619; // 0x01000193

/// FNV-1a 32-битный хеш произвольной последовательности байт.
///
/// Детерминирован и портируем: даёт побайтово идентичный беззнаковый `u32` в
/// Rust и в JS-зеркале (`packages/colors/fnv1a.js`) на одном и том же входе.
/// Вся арифметика — обёрточная (`wrapping_*`), поэтому 32-битное переполнение
/// Детерминирован и портируем в объявленном байтовом domain. Вся арифметика —
/// обёрточная (`wrapping_*`), поэтому 32-битное переполнение
/// корректно заворачивается по модулю 2^32 и НЕ паникует даже в debug-сборке.
///
/// Вызывающий сам кодирует вход в байты (напр. `s.as_bytes()` для UTF-8) —
Expand Down
7 changes: 4 additions & 3 deletions crates/labcolors-core/tests/data/fnv1a-vectors.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# FNV-1a 32-bit shared vectors. Single source of truth for the JS<->Rust
# differential test. Constants: offset_basis=2166136261 (0x811c9dc5),
# FNV-1a 32-bit published anchors + frozen Rust characterization.
# Constants: offset_basis=2166136261 (0x811c9dc5),
# prime=16777619 (0x01000193). Spec + published vectors:
# http://www.isthe.com/chongo/tech/comp/fnv/
# columns: group<TAB>name<TAB>kind<TAB>payload<TAB>expected(decimal u32)
# anchors carry the PUBLISHED reference expecteds (external ground truth);
# adversarial/fuzz expecteds are the cross-runtime oracle.
# adversarial/fuzz expecteds have no preserved independent provenance and are
# regression fixtures, not an external oracle.
anchor empty text 2166136261
anchor a text a 3826002220
anchor foobar text foobar 3214735720
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
//! Differential + anchor test for the portable FNV-1a 32-bit core primitive.
//! Published anchors and frozen characterization for the FNV-1a 32-bit core primitive.
//!
//! One source of truth for vectors: `tests/data/fnv1a-vectors.txt` (LF-pinned
//! TSV), shared byte-for-byte with the JS mirror test
//! (`packages/colors/test/fnv1a-differential.test.mjs`). Both sides recompute
//! every vector and assert equality against the committed expected (unsigned
//! decimal u32). Green on both = byte-identical JS==Rust output on every vector:
//! empty string, Cyrillic, emoji, high-bit bytes, an overflow-length key, and a
//! 500-vector randomized fuzz corpus.
//! `tests/data/fnv1a-vectors.txt` is an LF-pinned TSV. Every row is recomputed
//! against its committed unsigned `u32`: empty string, Cyrillic, emoji,
//! high-bit bytes, an overflow-length key, and a 500-vector randomized corpus.
//!
//! `anchor` rows carry the CANONICAL published FNV-1a values (external ground
//! truth, <http://www.isthe.com/chongo/tech/comp/fnv/>) so correctness is
//! grounded in the spec, not self-blessed. `text` rows are stored as literal
//! strings so this runtime exercises its OWN UTF-8 encoding path.
//! grounded in the spec, not self-blessed. The remaining committed expected
//! values have no preserved independent provenance and therefore protect only
//! frozen behaviour. `text` rows are literal strings so Rust exercises its own
//! UTF-8 encoding path.

use labcolors_core::fnv1a_32;

Expand Down Expand Up @@ -73,7 +71,7 @@ fn anchors_match_canonical_published_vectors() {
}

#[test]
fn adversarial_emoji_cyrillic_highbit_overflow_match_oracle() {
fn adversarial_emoji_cyrillic_highbit_overflow_match_frozen_characterization() {
let adv: Vec<_> = load()
.into_iter()
.filter(|v| v.group == "adversarial")
Expand All @@ -90,7 +88,7 @@ fn adversarial_emoji_cyrillic_highbit_overflow_match_oracle() {
}

#[test]
fn fuzz_500_frozen_vectors_match_oracle_cross_runtime() {
fn fuzz_500_vectors_match_frozen_characterization() {
let fuzz: Vec<_> = load().into_iter().filter(|v| v.group == "fuzz").collect();
assert!(
fuzz.len() >= 500,
Expand Down
12 changes: 7 additions & 5 deletions crates/labcolors-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ description = "WASM bindings for the labcolors-core contrast engine, packaged as
crate-type = ["cdylib", "rlib"]

[dependencies]
# Runtime owns only point evaluation and the adaptive theme engine. Offline
# compiler operations have a separate Cargo root and physical WASM artifact.
# Pre-cutover browser monolith: this crate still owns config JSON, legacy
# resolve/recheck bindings and point evaluators in one WASM artifact. The target
# architecture separates offline graph compilation from the runtime resolver;
# no second compiler crate or artifact exists in the current workspace.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
labcolors-core = { path = "../labcolors-core", default-features = false }
wasm-bindgen = { workspace = true }
# js-sys ships with the wasm-bindgen toolchain (no new third-party tree). It is
Expand All @@ -25,9 +27,9 @@ js-sys = "0.3"
# Derive-only: thiserror is a proc-macro with no runtime code, so it adds
# nothing to the WASM bundle while giving matchable, well-described errors.
thiserror = "2"
# Граница конфига: JSON живёт ТОЛЬКО в этом крейте (ядро — ноль
# runtime-зависимостей). Размер бандла отслеживается CI-шагом report bundle
# size (информационный до perf-bench).
# Граница pre-cutover конфига: JSON живёт ТОЛЬКО в этом крейте (ядро — ноль
# runtime-зависимостей). Raw release-WASM закреплён exact size ratchet в
# `packages/colors/bench/wasm.json`; это обязательный CI gate, а не отчёт.
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Expand Down
5 changes: 2 additions & 3 deletions crates/labcolors-wasm/src/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,7 @@ pub struct SolvedColor {
pub floor_override: bool,
/// Минимальное отношение WCAG из контракта роли (`AaText` → 4.5,
/// `AaUi` → 3.0) либо `None`, если пола нет. Solve проверяет финальную
/// эмитированную пару. Default runtime не удерживает пол на каждом
/// промежуточном кадре; `strict` использует охарактеризованный clamp, но не
/// является сертификатом.
/// эмитированную пару. Runtime-переход — только способ показа и не
/// сертифицирует этот пол на промежуточных кадрах.
pub legal_floor: Option<f64>,
}
16 changes: 9 additions & 7 deletions crates/labcolors-wasm/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,10 @@ impl Engine {
/// still pass and re-solves only the rare role that stably fails.
///
/// Returns a flat, interleaved buffer `[lc0, wcag0, lc1, wcag1, …]` (mapped to
/// a JS `Float64Array`) — no per-call object allocation on the hot path. The
/// values equal what the solver measured, so a freshly-resolved set rechecks
/// to its own reported contrasts.
/// a JS `Float64Array`) instead of a per-result object graph. The current
/// string ABI and implementation still allocate boundary and work buffers
/// per call. Values equal what the solver measured, so a freshly-resolved
/// set rechecks to its own reported contrasts.
pub fn recheck(
&self,
bg_hex: &str,
Expand All @@ -223,10 +224,11 @@ impl Engine {
// Accept the same hex forms as the background and `resolveTheme` (`#RGB`
// shorthand, missing `#`, any case) — but on this per-frame primitive,
// BORROW the input when it is already a valid 6-hex-digit colour so the
// common case (already-canonical `#RRGGBB` role hexes) allocates nothing.
// Only `#RGB` shorthand (or an otherwise-non-canonical form) allocates a
// normalised `String`. `srgb_from_hex` parses case- and `#`-insensitively,
// so a borrowed lower/upper/bare form yields the byte-identical colour.
// common case avoids a normalisation `String`. Boundary vectors,
// references, pairs and the flat output still allocate. `#RGB` shorthand
// (or another non-canonical form) additionally allocates a normalised
// `String`. `srgb_from_hex` parses case- and `#`-insensitively, so a
// borrowed lower/upper/bare form yields the byte-identical colour.
let bg = hex_for_recheck(bg_hex)?;
let fg_cows: Vec<Cow<'_, str>> = fg_hexes
.iter()
Expand Down
4 changes: 2 additions & 2 deletions crates/labcolors-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export interface SolvedColor {
/**
* Минимальное отношение WCAG из контракта роли: 4.5 для AA-текста, 3.0 для
* AA-UI или `null`, если пола нет. Solve проверяет финальную эмитированную
* пару. Default runtime не удерживает пол на каждом промежуточном кадре;
* `strict` использует охарактеризованный clamp, но не является сертификатом.
* пару. Runtime-переход — только способ показа и не сертифицирует этот пол
* на промежуточных кадрах.
*/
readonly legalFloor: number | null;
}
Expand Down
59 changes: 11 additions & 48 deletions packages/colors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ applyTheme(document.documentElement, result); // записать все --lab

```ts
import init, { LabColors, watchTheme } from "@labpics/colors";
import dsConfig from "./theme.config.json";

await init();
const colors = new LabColors();
Expand Down Expand Up @@ -95,35 +96,31 @@ watcher.stop(); // отключить наблюдателя
отслеживаемых метрик.

```ts
import init, { LabColors, adaptTheme, effectiveBackground } from "@labpics/colors";
import init, { LabColors, adaptTheme } from "@labpics/colors";
import dsConfig from "./theme.config.json";

await init();
const colors = new LabColors();
colors.loadConfig(JSON.stringify(dsConfig)); // конфиг дизайн-системы (см. квик-старт)

const surface = document.querySelector(".hero") as HTMLElement;
let samples = ["#101012"];
const adaptive = adaptTheme(surface, {
colors,
theme: "light",
background: () => effectiveBackground(surface, { fallback: "#101012" }),
background: () => samples,
});

adaptive.start(); // запустить внутренний requestAnimationFrame-цикл
samples = ["#101012", "#202024"]; // интеграция обновила конечные образцы подложки
adaptive.tick(); // явно обработать новое наблюдение
adaptive.setTheme("dark"); // смена темы применяется мгновенно
adaptive.stop(); // остановить цикл
```

Для градиента, изображения или видео интеграция может передать конечный набор
самостоятельно полученных образцов. Контроллер проверяет только переданные точки:
он не наблюдает всё поле и не переносит результат на промежутки между образцами.

```ts
adaptTheme(hero, {
colors,
theme: "light",
background: () => sampleBackdrop(hero), // например ["#0B0B0E", "#3A3A40"]
});
```
Для градиента, изображения или видео интеграция может передать конечный набор образцов,
полученных самостоятельно. Контроллер проверяет только переданные точки: он не
наблюдает всё поле и не переносит результат на промежутки между образцами.

### Инициализация в Node

Expand Down Expand Up @@ -436,7 +433,6 @@ interface AdaptThemeOptions {
sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120)
dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250)
easeMs?: number; // длительность перехода (по умолчанию 280; уменьшается при reduced-motion)
strict?: boolean; // legacy characterized clamp; не universal floor certificate (по умолчанию false)
reducedMotion?: boolean; // переопределить системную настройку
}

Expand All @@ -449,11 +445,6 @@ interface AdaptController {
}
```

`strict` сохраняет прежнее runtime-поведение, но не является доказательством
минимального или проходящего состояния на каждом кадре: путь
Oklab→gamut clip→sRGB8 немонотонен. Включайте его только явно для воспроизведения
этого legacy clamp, а не как режим корректности или читаемости.

Объявленный набор `background` обязан быть непустым и содержать только непустые
строки. Невалидный явный образец отклоняется до resolver без coercion и без
подмены fallback-цветом.
Expand All @@ -474,34 +465,6 @@ Glow-свидетельств и подготовка перехода обра

---

### `effectiveBackground(element, options?): string`

Возвращает непрозрачную опорную оценку `#RRGGBB` для поддерживаемой цепочки
сплошных и полупрозрачных DOM `background-color`. Это не browser pixel capture
и не сертификат цвета, который реально видит наблюдатель. Helper обходит цепочку
предков и композитит распознанные слои поверх `fallback` (по умолчанию белый).

```ts
const bg = effectiveBackground(panel); // например "#0F1014"
const bg2 = effectiveBackground(panel, { fallback: "#101012" });
```

**Честное ограничение:** работает только с поддерживаемыми сплошными и
полупрозрачными `background-color`; неподдерживаемый CSS, неполная прозрачная
цепочка, `background-image`, градиент, blur и video не дают полного наблюдения.
Текущий compatibility helper ещё может отбросить неподдерживаемый слой или
использовать fallback. Если такой контент влияет на решение, интеграция должна
передать собственный конечный набор образцов в `adaptTheme`; он расширяет только
набор проверенных точек и не превращается в наблюдение всего поля.

Дополнительно экспортируются вспомогательные функции для работы со слоями:
`parseCssColor`, `compositeOver`, `compositeStackToHex`, `toHex` и `oklabLerp`
(линейная интерполяция координат Oklab между двумя цветами с последующим
преобразованием в непрозрачный `#RRGGBB`). Alpha входов при этом отбрасывается,
а точность endpoints относится только к непрозрачным RGB-байтам.

---

## Размер бандла

Raw-размер WASM — hard gate. SSOT текущего exact Linux-x64 size-бюджета
Expand All @@ -522,7 +485,7 @@ release artifact.

Будет ли runtime-загрузка критическим путём первого рендера, определяет
интеграция: до первого `resolveTheme` инициализация обязана завершиться.
JS-хелперы (`applyTheme`, `watchTheme`, `adaptTheme`, `effectiveBackground`)
JS-хелперы (`applyTheme`, `watchTheme`, `adaptTheme`)
имеют именованные экспорты и допускают tree-shaking, но их размер также следует
мерить сборкой, а не описывать приблизительно.

Expand Down
8 changes: 0 additions & 8 deletions packages/colors/adapt-theme.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,6 @@ export interface AdaptThemeOptions {
dwellMs?: number;
/** Crossfade duration in ms. Default `280` (capped to a short fade under reduced motion). */
easeMs?: number;
/**
* Enable the legacy characterized per-frame clamp. The current
* Oklab→clip→sRGB8 path is not globally monotone, so this option is not a
* universal floor/least-blend or legibility certificate. Use it only when an
* integration explicitly needs the characterized legacy clamp. Default
* `false`.
*/
strict?: boolean;
/** Override reduced-motion detection (default reads `matchMedia`). */
reducedMotion?: boolean;
/** Clock injection (default `performance.now`/`Date.now`). */
Expand Down
Loading
Loading