diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 8be919b7..325fbf70 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -4,18 +4,23 @@ # набора). Запускается ночным workflow `.github/workflows/mutation.yml`. # # СКОУП: параметр-свободное ЯДРО МАТЕМАТИКИ — самый критичный, детерминированный, -# перцептивно-обоснованный код. Гигантские config.rs/solve.rs/semantic.rs НЕ +# включая exact alpha/composite и typed numerical decisions. Гигантские +# config.rs/solve.rs/semantic.rs НЕ # мутируются по умолчанию (часы прогона, малая маржинальная ценность на orchestration- # коде). Полный workspace-прогон — отдельный ручной/расширенный запуск (см. workflow). # # ЧТО ПРОВЕРЯЕТ: что property/golden/characterization тесты РЕАЛЬНО кусаются — # выживший мутант = молчаливая дыра в наборе (coverage=пол, mutation=правда). examine_globs = [ + "crates/labcolors-core/src/alpha.rs", "crates/labcolors-core/src/cleanliness.rs", + "crates/labcolors-core/src/glow.rs", + "crates/labcolors-core/src/material.rs", + "crates/labcolors-core/src/numerics.rs", "crates/labcolors-core/src/pair.rs", ] -# Запас времени на мутант: закон грязи считается быстро, но набор в целом не +# Запас времени на мутант: legacy proxy coordinate считается быстро, но набор в целом не # мгновенный; 3× базового прогона + пол в 60с гасит ложные timeout-«убийства». timeout_multiplier = 3.0 minimum_test_timeout = 60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aab9ca7..c577e1c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,70 @@ env: # To update: pick the new stable from https://forge.rust-lang.org/ and change # this single value — every job below consumes it via the `toolchain` input. RUST_TOOLCHAIN: 1.96.0 + # Public minimum, tested independently from the development toolchain. + MSRV_TOOLCHAIN: 1.85.0 + # Pins npm 11.9.0 as part of the official Node distribution. npm tar format + # is release input, so a floating runner-provided npm is not reproducible. + NODE_TOOLCHAIN: 24.14.0 + # Минимальный consumer runtime проверяется отдельно от release packer. + # Node 22 остаётся поддерживаемой upstream LTS-веткой. + NODE_CONSUMER_FLOOR: 22.11.0 + # Browser parity is release evidence too: pin both CfT archives byte-for-byte. + CHROME_FOR_TESTING_VERSION: 150.0.7871.115 + CHROME_FOR_TESTING_SHA256: 1be2db033133c5e2dd1a4e8664bf67b19a61bcf6ed28d2b00f433b3f0b4f9585 + CHROMEDRIVER_FOR_TESTING_SHA256: 6ac3919edd107ca13d08cccc118dc83821877e504014233f171bbd94cb01a80e jobs: + node-consumer-floor: + name: Node 22 consumer floor + runs-on: [self-hosted, Linux, X64] + needs: wasm + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ env.NODE_CONSUMER_FLOOR }} + - name: download exact artifact built by the canonical WASM job + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: colors-release-${{ github.sha }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/node-floor-artifact + - name: packed public runtime on declared Node floor + env: + ARTIFACT_DIR: ${{ runner.temp }}/node-floor-artifact + run: | + mapfile -t tarballs < <(find "$ARTIFACT_DIR" -type f -name '*.tgz' -print) + test "${#tarballs[@]}" -eq 1 + node scripts/verify-package-release.mjs --runtime-smoke "${tarballs[0]}" + + msrv: + name: MSRV workspace check + runs-on: [self-hosted, Linux, X64] + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: toolchain env (runner.temp) + run: | + echo "RUSTUP_HOME=$RUNNER_TEMP/rustup-$GITHUB_JOB" >> "$GITHUB_ENV" + echo "CARGO_HOME=$RUNNER_TEMP/cargo-$GITHUB_JOB" >> "$GITHUB_ENV" + echo "TMPDIR=$RUNNER_TEMP/tmp-$GITHUB_JOB" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/tmp-$GITHUB_JOB" + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27 + with: + toolchain: ${{ env.MSRV_TOOLCHAIN }} + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: cargo check on declared MSRV + run: cargo check --workspace --all-targets --locked + lint: name: clippy + rustfmt runs-on: [self-hosted, Linux, X64] @@ -34,6 +96,8 @@ jobs: # filled it in ~12 jobs (ENOSPC cascade, 2026-07-03). steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # dtolnay/rust-toolchain has no semver tags — pinned by commit SHA. # The toolchain VERSION is pinned via the explicit input below, not @stable, # so a new stable release cannot silently change CI behavior. @@ -58,7 +122,7 @@ jobs: - name: cargo fmt --check run: cargo fmt --all --check - name: cargo clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings docs: name: cargo doc (intra-doc links) @@ -69,6 +133,8 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: toolchain env (runner.temp) # runner-контекст недоступен в job-level env — только в шагах; поэтому # RUSTUP_HOME/CARGO_HOME назначаются здесь через GITHUB_ENV. Смысл тот @@ -94,7 +160,29 @@ jobs: # --no-deps keeps the gate on our own crates only. This stops a stale doc # reference from merging silently (main was green with ~81 broken links # because cargo doc was never gated — #26). - run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked + - name: package labcolors-core and run extracted package doctests + run: | + set -euo pipefail + test -L crates/labcolors-core/LICENSE + test "$(readlink crates/labcolors-core/LICENSE)" = "../../LICENSE" + cmp LICENSE crates/labcolors-core/LICENSE + cargo package -p labcolors-core --locked + crate_version="$(cargo pkgid -p labcolors-core | sed 's/.*#//')" + package_root="$RUNNER_TEMP/labcolors-core-package-$GITHUB_RUN_ID" + mkdir "$package_root" + tar -xzf "target/package/labcolors-core-${crate_version}.crate" -C "$package_root" + crate_dir="$package_root/labcolors-core-${crate_version}" + test -f "$crate_dir/README.md" + test -f "$crate_dir/LICENSE" + test ! -L "$crate_dir/LICENSE" + cmp LICENSE "$crate_dir/LICENSE" + grep -q '^license = "MIT"$' "$crate_dir/Cargo.toml" + if grep -q '^license-file[[:space:]]*=' "$crate_dir/Cargo.toml"; then + echo "packaged manifest must not carry both SPDX license and license-file" >&2 + exit 1 + fi + cargo test --doc --manifest-path "$crate_dir/Cargo.toml" --locked test: name: test @@ -105,6 +193,8 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: toolchain env (runner.temp) # runner-контекст недоступен в job-level env — только в шагах; поэтому # RUSTUP_HOME/CARGO_HOME назначаются здесь через GITHUB_ENV. Смысл тот @@ -129,7 +219,7 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: cargo test - run: cargo test --workspace + run: cargo test --workspace --locked audit: name: cargo audit (rustsec) @@ -140,6 +230,8 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: toolchain env (runner.temp) # runner-контекст недоступен в job-level env — только в шагах; поэтому # RUSTUP_HOME/CARGO_HOME назначаются здесь через GITHUB_ENV. Смысл тот @@ -188,6 +280,8 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: toolchain env (runner.temp) # runner-контекст недоступен в job-level env — только в шагах; поэтому # RUSTUP_HOME/CARGO_HOME назначаются здесь через GITHUB_ENV. Смысл тот @@ -196,11 +290,12 @@ jobs: run: | echo "RUSTUP_HOME=$RUNNER_TEMP/rustup-$GITHUB_JOB" >> "$GITHUB_ENV" echo "CARGO_HOME=$RUNNER_TEMP/cargo-$GITHUB_JOB" >> "$GITHUB_ENV" + echo "WASM_PACK_CACHE=$RUNNER_TEMP/wasm-pack-$GITHUB_JOB" >> "$GITHUB_ENV" # TMPDIR тоже с tmpfs: staging cargo install (aws-lc-sys ~ГБ) и # tempfile-доктесты забивали 8ГБ /tmp до 100% (4КБ свободно, все # джобы умирали «abnormal exit»/cc без stderr, 2026-07-03 18:34Z). echo "TMPDIR=$RUNNER_TEMP/tmp-$GITHUB_JOB" >> "$GITHUB_ENV" - mkdir -p "$RUNNER_TEMP/tmp-$GITHUB_JOB" + mkdir -p "$RUNNER_TEMP/tmp-$GITHUB_JOB" "$RUNNER_TEMP/wasm-pack-$GITHUB_JOB" - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27 with: toolchain: ${{ env.RUST_TOOLCHAIN }} @@ -220,7 +315,12 @@ jobs: - name: wasm-pack build (release) # The release bundle: panic=abort, no panic hook, wasm-opt -Oz. This is # the artifact @labpics/colors ships, so the size measured below is real. - run: wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors + run: wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ env.NODE_TOOLCHAIN }} + cache: npm + cache-dependency-path: packages/colors/package-lock.json - name: "@labpics/colors: typecheck + runtime tests" # Now that pkg/ exists (built above), the package's public types resolve # against the *real* wasm-bindgen declarations — so the type-level smoke @@ -233,13 +333,30 @@ jobs: npm ci npm run typecheck npm test + - name: verify byte-exact npm release artifact + id: verified-release + working-directory: packages/colors + run: npm run release:verify + # Upload the already verified release input before the pinned browser + # dependency is fetched. A Chrome outage therefore cannot destroy the + # evidence, while this `wasm` job still has to finish successfully before + # publish.yml will accept its canonical workflow run. + - name: upload verified npm tarball and manifest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: colors-release-${{ github.sha }}-attempt-${{ github.run_attempt }} + path: | + ${{ steps.verified-release.outputs.tarball }} + ${{ steps.verified-release.outputs.manifest }} + if-no-files-found: error + include-hidden-files: true + retention-days: 90 - name: install Chrome + dependencies (Chrome for Testing + apt-get download) # Self-hosted WSL runners lack Chrome and passwordless sudo. # Strategy: - # 1. Download CfT (Chrome for Testing) — stable channel; chrome + chromedriver. - # wasm-pack reads CHROME_PATH to locate the browser; CHROMEDRIVER for driver. - # wasm-pack 0.13.1 ignores CHROMEDRIVER in cargo-test invocation and downloads - # its own chromedriver, but LD_LIBRARY_PATH covers that binary too. + # 1. Download exact CfT chrome + chromedriver archives and verify SHA-256. + # wasm-pack reads CHROME_PATH for the browser but ignores a CHROMEDRIVER + # variable, so the matching driver is passed through --chromedriver. # 2. Extract required shared libs (.so) from Ubuntu .deb packages without sudo: # `apt-get download` fetches .deb; `dpkg --extract` unpacks without root. # Chrome/chromedriver need libnspr4, libnss3, libasound2t64, libgbm1 @@ -248,24 +365,29 @@ jobs: run: | set -euo pipefail # -- CfT chrome + chromedriver -- - CfT_VERSION=$(curl -s https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json \ - | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const j=JSON.parse(d);console.log(j.channels.Stable.version);});") - BASE_URL="https://storage.googleapis.com/chrome-for-testing-public/${CfT_VERSION}/linux64" - mkdir -p "$HOME/.chrome-for-testing" - curl -sSL "${BASE_URL}/chrome-linux64.zip" -o "$HOME/.chrome-for-testing/chrome-linux64.zip" - curl -sSL "${BASE_URL}/chromedriver-linux64.zip" -o "$HOME/.chrome-for-testing/chromedriver-linux64.zip" - unzip -q -o "$HOME/.chrome-for-testing/chrome-linux64.zip" -d "$HOME/.chrome-for-testing/" - unzip -q -o "$HOME/.chrome-for-testing/chromedriver-linux64.zip" -d "$HOME/.chrome-for-testing/" - CHROME_BIN="$HOME/.chrome-for-testing/chrome-linux64/chrome" - CHROMEDRIVER_BIN="$HOME/.chrome-for-testing/chromedriver-linux64/chromedriver" + BASE_URL="https://storage.googleapis.com/chrome-for-testing-public/${CHROME_FOR_TESTING_VERSION}/linux64" + CHROME_ROOT="$RUNNER_TEMP/chrome-$GITHUB_JOB" + mkdir -p "$CHROME_ROOT" + curl --fail-with-body --silent --show-error --location \ + "${BASE_URL}/chrome-linux64.zip" -o "$CHROME_ROOT/chrome-linux64.zip" + curl --fail-with-body --silent --show-error --location \ + "${BASE_URL}/chromedriver-linux64.zip" -o "$CHROME_ROOT/chromedriver-linux64.zip" + printf '%s %s\n' "$CHROME_FOR_TESTING_SHA256" "$CHROME_ROOT/chrome-linux64.zip" \ + | sha256sum --check --strict + printf '%s %s\n' "$CHROMEDRIVER_FOR_TESTING_SHA256" "$CHROME_ROOT/chromedriver-linux64.zip" \ + | sha256sum --check --strict + unzip -q -o "$CHROME_ROOT/chrome-linux64.zip" -d "$CHROME_ROOT" + unzip -q -o "$CHROME_ROOT/chromedriver-linux64.zip" -d "$CHROME_ROOT" + CHROME_BIN="$CHROME_ROOT/chrome-linux64/chrome" + CHROMEDRIVER_BIN="$CHROME_ROOT/chromedriver-linux64/chromedriver" chmod +x "$CHROME_BIN" "$CHROMEDRIVER_BIN" echo "CHROME_PATH=$CHROME_BIN" >> "$GITHUB_ENV" - echo "CfT_CHROMEDRIVER=$CHROMEDRIVER_BIN" >> "$GITHUB_ENV" - echo "Chrome for Testing $CfT_VERSION installed" + echo "CHROMEDRIVER_PATH=$CHROMEDRIVER_BIN" >> "$GITHUB_ENV" + echo "Chrome for Testing $CHROME_FOR_TESTING_VERSION installed" # -- Extract Chrome shared-lib dependencies from Ubuntu .deb (no sudo needed) -- - DEPS_DIR="$HOME/.chromedeps" - DEBS_DIR="$HOME/.chromedeps/debs" + DEPS_DIR="$RUNNER_TEMP/chrome-deps-$GITHUB_JOB" + DEBS_DIR="$DEPS_DIR/debs" mkdir -p "$DEBS_DIR" # libnspr4/libnss3: NSS/NSPR crypto deps for Chrome and chromedriver. # libasound2t64: ALSA (renamed from libasound2 in Ubuntu 24.04 noble). @@ -279,33 +401,19 @@ jobs: echo "Chrome deps: $DEPS_LIB" LD_LIBRARY_PATH="${DEPS_LIB}:${LD_LIBRARY_PATH:-}" "$CHROME_BIN" --version || true - # -- Pre-seed wasm-pack chromedriver cache with our verified CfT chromedriver -- - # wasm-pack 0.13.1 caches chromedriver at ~/.cache/.wasm-pack/chromedriver-{hash}/ - # and SKIPS download if binary exists (checked by path existence). Our diagnostic - # confirmed that our CfT chromedriver works while wasm-pack's download fails. - # Pre-seed ALL possible hash slots so wasm-pack uses ours regardless of which - # Chrome version it expects. Hash bb6facf4ea9511f6 observed for 150.0.7871.24. - WASM_PACK_CACHE="$HOME/.cache/.wasm-pack" - mkdir -p "$WASM_PACK_CACHE" - # Seed the known hash slot first, then create a guard for any new version: - for hash_dir in "chromedriver-bb6facf4ea9511f6"; do - mkdir -p "$WASM_PACK_CACHE/$hash_dir" - cp "$CHROMEDRIVER_BIN" "$WASM_PACK_CACHE/$hash_dir/chromedriver" - chmod +x "$WASM_PACK_CACHE/$hash_dir/chromedriver" - done - echo "Pre-seeded wasm-pack chromedriver cache with CfT binary" - # -- Make Chrome discoverable by chromedriver without explicit binary path -- # chromedriver searches PATH for: google-chrome, google-chrome-stable, chromium # When CHROME_PATH/binary is not passed by wasm-bindgen-test-runner, chromedriver - # needs to find Chrome in PATH. Add ~/bin to PATH with our CfT binary linked. - mkdir -p "$HOME/bin" - ln -sf "$CHROME_BIN" "$HOME/bin/google-chrome" - ln -sf "$CHROME_BIN" "$HOME/bin/google-chrome-stable" + # needs to find Chrome in PATH. Add an isolated runner.temp bin directory + # with Chrome aliases; wasm-pack receives the driver path explicitly. + CHROME_BIN_DIR="$RUNNER_TEMP/chrome-bin-$GITHUB_JOB" + mkdir -p "$CHROME_BIN_DIR" + ln -sf "$CHROME_BIN" "$CHROME_BIN_DIR/google-chrome" + ln -sf "$CHROME_BIN" "$CHROME_BIN_DIR/google-chrome-stable" # Use GITHUB_PATH (step-scoped prepend) rather than PATH in GITHUB_ENV # (which leaks the full expanded PATH across jobs on shared self-hosted runners). - echo "$HOME/bin" >> "$GITHUB_PATH" - echo "Linked CfT Chrome as google-chrome in ~/bin (for chromedriver path discovery)" + echo "$CHROME_BIN_DIR" >> "$GITHUB_PATH" + echo "Linked CfT Chrome under runner.temp" # -- Diagnose: verify chromedriver can start a Chrome session (surfaces Chrome stderr) -- # Note: LD_LIBRARY_PATH in $GITHUB_ENV is not active in this step yet; @@ -350,7 +458,7 @@ jobs: - name: wasm-pack test (headless chrome) # Chrome is provisioned above; the parity smoke runs against native # resolve_set inside the same wasm runtime. D1 default from the chapter. - run: wasm-pack test --headless --chrome crates/labcolors-wasm + run: wasm-pack test --headless --chrome --chromedriver "$CHROMEDRIVER_PATH" crates/labcolors-wasm --locked - name: report bundle size (gzip) # Size is part of the perf story (perf-bench). Not a hard gate yet — a # budget gate lands with perf-bench; here we surface the number in logs. @@ -359,7 +467,6 @@ jobs: glue="packages/colors/pkg/labcolors.js" echo "wasm: raw=$(wc -c <"$wasm")B gzip=$(gzip -9 -c "$wasm" | wc -c)B" echo "glue: raw=$(wc -c <"$glue")B gzip=$(gzip -9 -c "$glue" | wc -c)B" - docs-drift: name: docs-drift (нейминг-канон) runs-on: [self-hosted, Linux, X64] @@ -369,11 +476,13 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # Гейт «доки == реальность» (порт эталона labui#150 / lab-icons#53 / # lab-motion): docs/NAMING.md сверяется с фактами ФС. Node уже стоит на - # self-hosted раннерах (job `wasm` зовёт node/npm без setup-node); - # Rust-тулчейн не нужен — гейт читает только ФС, поэтому джоба лёгкая - # и не трогает RUSTUP_HOME/CARGO_HOME-изоляцию остальных джоб. + # self-hosted раннерах; точная версия npm здесь не release input, потому + # setup-node не нужен. Rust-тулчейн тоже не нужен — гейт читает только ФС, + # поэтому джоба лёгкая и не трогает изоляцию остальных джоб. - name: юниты гейта run: node --test scripts/docs-drift.test.mjs - name: check-docs-drift (docs/NAMING.md == факты ФС) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 0b019af7..dccde453 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -34,6 +34,8 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: toolchain env (runner.temp) # Изоляция RUSTUP_HOME/CARGO_HOME/TMPDIR per job под runner.temp — тот же # приём, что во всех джобах ci.yml (класс утечки /tmp закрыт, история #143). @@ -54,7 +56,8 @@ jobs: cargo install cargo-mutants --version 25.0.0 --locked echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" - name: cargo mutants (математ-ядро — скоуп из .cargo/mutants.toml) - # Без флагов скоуп берётся из .cargo/mutants.toml (cleanliness.rs + pair.rs). + # Без флагов скоуп берётся из .cargo/mutants.toml (alpha/cleanliness/ + # glow/material/numerics/pair). # workflow_dispatch с full_workspace=true снимает скоуп (--file '**/*.rs'). run: | if [ "${{ inputs.full_workspace }}" = "true" ]; then diff --git a/.github/workflows/native-conformance.yml b/.github/workflows/native-conformance.yml index c64c3047..ea75e40d 100644 --- a/.github/workflows/native-conformance.yml +++ b/.github/workflows/native-conformance.yml @@ -7,8 +7,10 @@ name: Native conformance (Swift) # ВАЖНО (владелец, 2026-07-08): GitHub-hosted macOS-раннеры платные и ИСКЛЮЧЕНЫ # навсегда — биллинг чиниться не будет. Поэтому Swift валидируется на # self-hosted Linux-раннере (de-02) в официальном swift-контейнере — тот же -# UniFFI-биндинг, тот же пак, ядро под x86_64-unknown-linux-gnu. Кросс-платформа -# держится толерантностью DRIFT_TOL=1e-6 (см. conformance/README.md). +# UniFFI-биндинг, тот же пак, ядро под x86_64-unknown-linux-gnu. Числовые +# семейства пака держатся толерантностью DRIFT_TOL=1e-6 +# (см. conformance/README.md). Glow stable проверяется как типизированный +# Indeterminate; для legacy CAM16 bit parity не заявляется. # # Линтеры/аудит/wasm/основной `cargo test --workspace` (включая толерантный # раннер-референс пака на Linux) — в ci.yml. Здесь — Swift-слой. @@ -22,6 +24,10 @@ on: permissions: contents: read +env: + RUST_TOOLCHAIN: 1.96.0 + SWIFT_CONTAINER: swift:6.1.3@sha256:e1cdaf7ddc9de37d8561da7a260535236694fca8c1b67d3129d47d8b180a9394 + jobs: # АКТИВНЫЙ ГЕЙТ: Swift-conformance на self-hosted Linux через swift-контейнер. # Зеркало проверенного локального прогона (bindings/swift/ci/run-conformance.sh). @@ -35,16 +41,19 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - # Официальный swift:6.1 (Ubuntu). Внутри ставится rustup, ядро собирается + # Официальный Swift 6.1.3 (Ubuntu), pinned по manifest-list digest. + # Внутри ставится pinned Rust, ядро собирается # под Linux, генерятся Swift-биндинги, swift test прогоняет пак. Скрипт — # единый источник с локальным прогоном (bindings/swift/ci/run-conformance.sh). # $GITHUB_WORKSPACE монтируется read-only; сборка идёт в /work в контейнере. - - name: swift conformance in swift:6.1 container + - name: swift conformance in swift:6.1.3 container run: | docker run --rm \ -v "$GITHUB_WORKSPACE":/src:ro \ - swift:6.1 \ + "$SWIFT_CONTAINER" \ bash /src/bindings/swift/ci/run-conformance.sh # ССЫЛОЧНАЯ джоба (НЕ активный гейт): нативный macOS/arm64. Требует ПЛАТНЫХ @@ -58,21 +67,23 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27 with: - toolchain: 1.96.0 + toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: versions run: | swift --version rustc --version - name: cargo test (reference runner + ffi units) - run: cargo test -p labcolors-conformance -p labcolors-ffi + run: cargo +${{ env.RUST_TOOLCHAIN }} test -p labcolors-conformance -p labcolors-ffi --locked - name: build labcolors-ffi (+ bindgen bin) - run: cargo build -p labcolors-ffi --features cli + run: cargo +${{ env.RUST_TOOLCHAIN }} build -p labcolors-ffi --features cli --locked - name: generate Swift bindings run: | - cargo run -p labcolors-ffi --features cli --bin uniffi-bindgen -- \ + cargo +${{ env.RUST_TOOLCHAIN }} run -p labcolors-ffi --features cli --bin uniffi-bindgen --locked -- \ generate --library target/debug/liblabcolors.dylib \ --language swift --out-dir bindings/swift/generated - name: arrange generated bindings diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7762de7..578b991d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,49 +1,39 @@ # Публикация @labpics/colors в npm по тегу colors-vX.Y.Z. # -# БЕЗ ТОКЕНОВ: аутентификация — Trusted Publishing (OIDC): npm доверяет -# именно этому workflow этого репозитория (настройка — страница пакета -# npmjs.com → Settings → Trusted publishing; привязка включает environment -# npm-publish — второй фактор доверия поверх имени workflow). Требования -# OIDC: npm ≥ 11.5.1, Node ≥ 22.14 — Node 24 несёт npm 11.x из коробки. +# Self-hosted runner публикует через explicit granular token: npm не поддерживает +# безтокенный trust flow на self-hosted runners. Secret `NPM_TOKEN` хранится в +# environment `npm-publish`, выдаёт write только пакету @labpics/colors и +# передаётся ровно publish-step. # -# Provenance-аттестация ОТКЛЮЧЕНА (package.json publishConfig.provenance:false): -# npm выдаёт sigstore-provenance ТОЛЬКО публичным репозиториям (E422 на -# приватном). При открытии lab-colors публично — вернуть provenance:true -# (аттестация «собрано этим workflow из этого репо» усилит сигналы Socket). +# Publish ничего не собирает и не перепаковывает. Он принимает только immutable +# attempt-scoped artifact `colors-release-$GITHUB_SHA-attempt-$RUN_ATTEMPT` из +# canonical successful push-run +# `.github/workflows/ci.yml` для exact tag SHA. Отдельно требуется canonical +# successful push-run `.github/workflows/native-conformance.yml`. Имена jobs +# проверяются только внутри выбранных run IDs, поэтому чужой check-run с таким +# же display name не может удовлетворить gate. # -# Self-hosted раннер — ВРЕМЕННО, по решению владельца (2026-07-04): публикация -# 0.6.0 не стартовала на github-hosted («account payments have failed», -# run 28689086170), платные минуты закрыты. -# -# Границы гарда «тег достижим из origin/main» (честно, по ревью): -# — закрывает: rogue-тег, переиспользующий ЭТОТ workflow, — код вне истории -# main (PR-only + зелёный CI) отклоняется до любых установок из сети; -# — НЕ закрывает: тег на коммит с переписанным publish.yml (событие тега -# исполняет workflow из коммита тега). Полный гейт — required reviewers -# на environment npm-publish — на текущем биллинг-плане НЕДОСТУПЕН -# (HTTP 422 при попытке включить, 2026-07-04). -# Почему это не расширение поверхности: (а) ci.yml уже исполняет произвольный -# код ЛЮБОЙ ветки write-холдера на том же self-hosted раннере — крафт-тег не -# добавляет нового; (б) вектор «крафт-publish.yml + OIDC-паблиш» существовал -# и на github-hosted. Дельта self-hosted-перехода = 0 при текущем круге write -# (владелец + агент). Остаточный класс — co-tenancy de-02; закрывается -# выделенным CI-узлом (эпик изоляции раннеров). Вернуть github-hosted / -# включить required reviewers, когда починен биллинг. -# -# Гард «версия пакета == тег» стоит ДО любых установок из сети: fail-fast -# на расхождении, публикация «тег v0.2.0 / пакет 0.1.0» невозможна. +# Provenance-аттестация отключена (package.json publishConfig.provenance:false): +# npm выдаёт sigstore provenance только публичным репозиториям. При открытии +# репозитория вернуть provenance и короткоживущую аутентификацию. name: publish +concurrency: + group: npm-publish + cancel-in-progress: false + on: push: tags: ["colors-v*"] env: - RUST_TOOLCHAIN: "1.96.0" + # Node 24.14.0 ships the npm 11.9.0 release packer/publisher contract. + NODE_TOOLCHAIN: "24.14.0" + NPM_TOOLCHAIN: "11.9.0" permissions: contents: read - id-token: write # OIDC для Trusted Publishing + actions: read jobs: publish: @@ -52,45 +42,277 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - # Полная история: гард ниже проверяет достижимость тега из main - # через merge-base, shallow-клон дал бы ложный отказ. + # Полная история нужна ancestry guard против постороннего тега. fetch-depth: 0 - - name: гард — тег из истории main (anti-rogue-tag, до установок из сети) + persist-credentials: false + - name: isolated publish env (runner.temp) run: | + echo "TMPDIR=$RUNNER_TEMP/tmp-$GITHUB_JOB" >> "$GITHUB_ENV" + echo "NPM_CONFIG_CACHE=$RUNNER_TEMP/npm-$GITHUB_JOB" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/tmp-$GITHUB_JOB" "$RUNNER_TEMP/npm-$GITHUB_JOB" + - name: guard — exact tag SHA is in origin/main + run: | + set -euo pipefail + checked_out="$(git rev-parse HEAD)" + if [ "$checked_out" != "$GITHUB_SHA" ]; then + echo "checkout HEAD $checked_out != tag SHA $GITHUB_SHA" >&2 + exit 1 + fi git fetch --no-tags origin main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - echo "тег $GITHUB_REF_NAME указывает вне истории origin/main — публикация запрещена" >&2 + echo "tag $GITHUB_REF_NAME points outside origin/main history" >&2 exit 1 fi - - name: версия пакета == тег (fail-fast, до установок из сети) - working-directory: packages/colors + - name: guard — canonical exact-SHA workflow runs and their own jobs + id: canonical-runs + env: + GH_READ_TOKEN: ${{ github.token }} run: | - tag="${GITHUB_REF_NAME#colors-v}" - version="$(node -p "require('./package.json').version")" - if [ "$tag" != "$version" ]; then - echo "тег colors-v$tag != package.json $version" >&2 - exit 1 - fi - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27 (у action нет релизных тегов; бамп сверять с историей master) + node <<'NODE' + const { appendFileSync } = require("node:fs"); + + const repository = process.env.GITHUB_REPOSITORY; + const expectedSha = process.env.GITHUB_SHA; + const apiRoot = process.env.GITHUB_API_URL; + const output = process.env.GITHUB_OUTPUT; + const token = process.env.GH_READ_TOKEN; + if (!repository || !expectedSha || !apiRoot || !output || !token) { + throw new Error("canonical-run guard is missing required GitHub environment"); + } + + const workflows = [ + { + key: "ci", + file: "ci.yml", + path: ".github/workflows/ci.yml", + requiredJobs: [ + "Node 22 consumer floor", + "MSRV workspace check", + "clippy + rustfmt", + "cargo doc (intra-doc links)", + "test", + "cargo audit (rustsec)", + "wasm build + headless test + size", + "docs-drift (нейминг-канон)", + ], + }, + { + key: "native", + file: "native-conformance.yml", + path: ".github/workflows/native-conformance.yml", + requiredJobs: [ + "swift conformance (self-hosted Linux, swift container)", + ], + }, + ]; + + async function api(path) { + const response = await fetch(`${apiRoot}/repos/${repository}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) { + throw new Error(`GitHub API ${response.status} for ${path}: ${await response.text()}`); + } + return response.json(); + } + + async function paginated(path, field) { + const values = []; + for (let page = 1; page <= 10; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const payload = await api(`${path}${separator}per_page=100&page=${page}`); + if (!Array.isArray(payload[field])) { + throw new Error(`GitHub API response has no ${field} array for ${path}`); + } + values.push(...payload[field]); + if (payload[field].length < 100) return values; + } + throw new Error(`GitHub API pagination exceeded 1000 ${field} records for ${path}`); + } + + async function canonicalRun(spec) { + const query = new URLSearchParams({ + branch: "main", + event: "push", + head_sha: expectedSha, + }); + const runs = await paginated( + `/actions/workflows/${encodeURIComponent(spec.file)}/runs?${query}`, + "workflow_runs", + ); + const candidates = runs + .filter((run) => + run.path === spec.path && + run.head_sha === expectedSha && + run.head_branch === "main" && + run.event === "push") + .sort((left, right) => Number(right.id) - Number(left.id)); + const run = candidates[0]; + if (!run) throw new Error(`${spec.path}: no canonical push run for ${expectedSha}`); + if (!(run.status === "completed" && run.conclusion === "success")) { + throw new Error(`${spec.path} run ${run.id}: ${run.status}/${run.conclusion ?? "none"}`); + } + return run; + } + + async function requireSuccessfulJobs(spec, run) { + const jobs = await paginated( + `/actions/runs/${run.id}/jobs?filter=latest`, + "jobs", + ); + const failures = []; + for (const name of spec.requiredJobs) { + const matches = jobs.filter((job) => + job.name === name && Number(job.run_id) === Number(run.id)); + if (matches.length !== 1) { + failures.push(`${name}: expected one job in run ${run.id}, got ${matches.length}`); + continue; + } + const job = matches[0]; + if (!(job.status === "completed" && job.conclusion === "success")) { + failures.push(`${name}: ${job.status}/${job.conclusion ?? "none"}`); + } + } + if (failures.length > 0) { + throw new Error(`${spec.path} failed job gate:\n${failures.join("\n")}`); + } + } + + (async () => { + const outputs = []; + for (const spec of workflows) { + const run = await canonicalRun(spec); + await requireSuccessfulJobs(spec, run); + outputs.push(`${spec.key}_run_id=${run.id}`); + if (spec.key === "ci") { + if (!Number.isSafeInteger(run.run_attempt) || run.run_attempt < 1) { + throw new Error(`${spec.path} run ${run.id}: invalid run_attempt`); + } + outputs.push(`ci_run_attempt=${run.run_attempt}`); + } + console.log(`${spec.path}: canonical successful push run ${run.id}`); + } + appendFileSync(output, `${outputs.join("\n")}\n`, "utf8"); + })().catch((error) => { + console.error(error instanceof Error ? error.stack : String(error)); + process.exitCode = 1; + }); + NODE + - name: download immutable exact-SHA release artifact from canonical CI run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: install wasm-pack - run: cargo install wasm-pack --version 0.13.1 --locked - - name: wasm-pack build (release) - # Тот же артефакт, что гейтится в ci.yml (panic=abort, wasm-opt -Oz). - run: wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors + name: colors-release-${{ github.sha }}-attempt-${{ steps.canonical-runs.outputs.ci_run_attempt }} + path: ${{ runner.temp }}/colors-release-${{ github.sha }}-attempt-${{ steps.canonical-runs.outputs.ci_run_attempt }} + github-token: ${{ github.token }} + run-id: ${{ steps.canonical-runs.outputs.ci_run_id }} - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: "24" + node-version: ${{ env.NODE_TOOLCHAIN }} registry-url: "https://registry.npmjs.org" - - name: гейт перед публикацией — typecheck + runtime-тесты пакета - working-directory: packages/colors + - name: validate manifest identity and byte-exact tarball + id: verified-artifact + env: + ARTIFACT_DIR: ${{ runner.temp }}/colors-release-${{ github.sha }}-attempt-${{ steps.canonical-runs.outputs.ci_run_attempt }} + EXPECTED_SHA: ${{ github.sha }} + EXPECTED_TAG: ${{ github.ref_name }} + EXPECTED_NODE: ${{ env.NODE_TOOLCHAIN }} + EXPECTED_NPM: ${{ env.NPM_TOOLCHAIN }} run: | - npm ci - npm run typecheck - npm test - - name: npm publish (OIDC, без токена) - working-directory: packages/colors - run: npm publish + node <<'NODE' + const { createHash } = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const { + appendFileSync, + lstatSync, + readFileSync, + readdirSync, + } = require("node:fs"); + const { basename, join } = require("node:path"); + + function fail(message) { + throw new Error(`release artifact rejected: ${message}`); + } + + function walk(directory, files = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) fail(`symlink is forbidden: ${path}`); + if (stat.isDirectory()) walk(path, files); + else if (stat.isFile()) files.push(path); + else fail(`non-regular artifact entry: ${path}`); + } + return files; + } + + const artifactDir = process.env.ARTIFACT_DIR; + const expectedSha = process.env.EXPECTED_SHA; + const expectedTag = process.env.EXPECTED_TAG; + const expectedNode = process.env.EXPECTED_NODE; + const expectedNpm = process.env.EXPECTED_NPM; + const output = process.env.GITHUB_OUTPUT; + if (!artifactDir || !expectedSha || !expectedTag || !expectedNode || !expectedNpm || !output) { + fail("validator is missing required environment"); + } + if (process.versions.node !== expectedNode) { + fail(`Node ${process.versions.node} != ${expectedNode}`); + } + const npmVersion = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); + if (npmVersion !== expectedNpm) fail(`npm ${npmVersion} != ${expectedNpm}`); + if (!/^colors-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(expectedTag)) { + fail(`invalid release tag ${expectedTag}`); + } + + const files = walk(artifactDir); + const manifests = files.filter((path) => basename(path) === "release-manifest.json"); + const tarballs = files.filter((path) => path.endsWith(".tgz")); + if (files.length !== 2 || manifests.length !== 1 || tarballs.length !== 1) { + fail(`expected exactly one manifest and one tarball, got ${files.length} files`); + } + + const manifest = JSON.parse(readFileSync(manifests[0], "utf8")); + const expectedVersion = expectedTag.slice("colors-v".length); + if (manifest.schemaVersion !== 1) fail(`manifest schema ${manifest.schemaVersion}`); + if (manifest.sourceSha !== expectedSha) { + fail(`manifest sourceSha ${manifest.sourceSha} != ${expectedSha}`); + } + if (manifest.npm !== expectedVersion) { + fail(`manifest npm ${manifest.npm} != tag version ${expectedVersion}`); + } + + const tarball = tarballs[0]; + const bytes = readFileSync(tarball); + const evidence = manifest.artifacts?.tarball; + if (!evidence || evidence.path !== `.release/${basename(tarball)}`) { + fail("manifest tarball path does not identify the downloaded tarball"); + } + if (!Number.isSafeInteger(evidence.bytes) || evidence.bytes !== bytes.length) { + fail(`tarball bytes ${bytes.length} != manifest ${evidence.bytes}`); + } + const digest = createHash("sha256").update(bytes).digest("hex"); + if (!/^[0-9a-f]{64}$/u.test(evidence.sha256) || evidence.sha256 !== digest) { + fail(`tarball sha256 ${digest} != manifest ${evidence.sha256}`); + } + + const packedPackage = JSON.parse( + execFileSync("tar", ["-xOzf", tarball, "package/package.json"], { + encoding: "utf8", + }), + ); + if (packedPackage.name !== "@labpics/colors" || packedPackage.version !== expectedVersion) { + fail(`packed identity ${packedPackage.name}@${packedPackage.version}`); + } + + if (tarball.includes("\n") || tarball.includes("\r")) fail("unsafe tarball path"); + appendFileSync(output, `tarball=${tarball}\n`, "utf8"); + console.log(`verified ${basename(tarball)}: ${bytes.length} bytes, sha256 ${digest}`); + NODE + - name: npm publish verified CI tarball (granular token, no rebuild/repack) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + TARBALL_PATH: ${{ steps.verified-artifact.outputs.tarball }} + run: npm publish --ignore-scripts "$TARBALL_PATH" diff --git a/.gitignore b/.gitignore index 2359fc86..9ee12379 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,14 @@ dist/ # Rust target/ -Cargo.lock .git-pr-body.md +# Release verification outputs and package-local copy of the canonical root +# license are generated by prepack/release:verify. +packages/colors/.release/ +packages/colors/LICENSE +packages/colors/build-metadata.json + # Психофизический харнесс: сгенерированные манифесты/раннеры/экспорты сессий — # это артефакты запуска (детерминированы из seed), в гит не коммитятся. experiments/psychophysics/out/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..803826d5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,129 @@ +# Changelog + +Все существенные изменения Lab Colors фиксируются в этом файле. Версии npm и +Rust различаются, потому что это разные delivery surfaces одного контракта. + +## [@labpics/colors 0.10.0 / Rust 0.2.0] - 2026-07-11 + +Breaking release относительно `@labpics/colors` 0.9.1 / Rust 0.1.0. Пошаговый +переход и rollback: [exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md). + +Release packer воспроизводимо закреплён на Node 24.14/npm 11.9; публичный +consumer contract отделён и проверяется с первого Node 22 LTS — `22.11.0`. + +### Breaking + +- Каждый client-owned Glow-рецепт требует explicit `decision_profile`; default + и silent legacy fallback удалены. Профиль входит в config fingerprint. +- `GlowRole` стал union из determinate `kind: "glow"` и typed terminal + `kind: "glow-indeterminate"`. Indeterminate не эмитит halo/core/alpha CSS vars. +- Единый `referenceProfile` из pre-release API заменён раздельными + `compositeProfile` / `compositeGuarantee`, `layerRecipeProfile`, + `appearanceDiagnosticProfile`, nullable `selectionDiagnosticProfile` и + `decisionProfile` / `decisionGuarantee`. +- `solve_screen_alpha_for_dj` принимает `GlowDecisionProfileV1` и возвращает + `NumericalDecisionV1`; поля `GlowSolve` доступны через getters. +- `resolve_alpha_analog_hex` возвращает `Result<(String, f64), String>` вместо + вложенного `Option`. Для валидного домена sRGB8-ответ тотален; недоменная + alpha возвращает `Err`, а не клампится. +- Публичные continuous/point compositor-границы возвращают `Result` и одинаково + отвергают нечисловой или внедиапазонный ввод в debug/release. +- Поля `BackdropBox` закрыты; `try_new` и material-функции возвращают typed + validation/solve errors. `MaterialAlpha` сообщает typed status и numerical + guarantee вместо неявного `Option`/public fields. +- `labcolors-conformance::generate_solve` и `Pack::generate` теперь возвращают + `Result<_, PackGenerationError>`: внутренний/неизвестный core failure нельзя + сериализовать как обычную физическую недостижимость. + +### Added + +- Exact encoded-sRGB8 source-over и screen profiles с bit-exact composite + certificate, binary64 identity alpha и канонической `alphaCss`. +- Machine-readable registry branch-sensitive numerical sites и typed + `Determinate` / `Indeterminate` с причиной и sound bounds либо честным + `bounds: unavailable`. +- Material alpha несёт `BisectionBracketCharacterizedV1`: выбранный после 60 + шагов upper candidate повторно проверен, lower candidate не держит floor, а + numerical profile равен + `encoded-srgb-byte-scale-affine-platform-binary64-powf-v1`. + Transparent/opaque endpoints имеют отдельные typed variants; wire несёт + primary `alphaStatus`. +- Determinate Glow сообщает отдельные halo/core point-композиты и diagnostic + `|ΔJ′|`, provenance recipe/appearance/selection, target status, constraint + layer и классы гарантий. +- Conformance pack 2.0.0 с alpha half-tie-вектором. +- Публикуемый `labcolors-core` теперь несёт package-local README; CI проверяет + реальный `.crate`, распаковывает его и исполняет doctest вне workspace tree. +- `@labpics/colors/build-metadata.json` экспортирует machine-readable связь + npm/core versions, source SHA, conformance hashes и точных WASM bytes/hash; + release verifier повторно сверяет её после чистой установки tarball. + +### Fixed + +- Source-over half-tie считается в byte-reference порядке: `#C0B2FA @ 0.122` + над `#000000` даёт `#17161F`, без потери соседнего LSB при нормализации. +- Glow перечисляет first-passing alpha фактического binary64 screen-композитора: + алгебраически равные rational walls больше не склеивают достижимый ULP-seam + state. Доказанная граница потока — ≤ 766 states; tight maximum не заявлен. +- Material-alpha использует тот же byte-scale affine порядок + `B + α·(T−B)`, что официальный JS-потребитель. На seam fixtures + `#020202/floor=3` и `#000000/floor=7` прежний normalized-expanded upper + candidate не проходил повторную consumer-проверку на один шаг binary64. +- Material all-backdrop recheck больше не полагается на ложную двухугловую + монотонность: conservative binary64 channel envelope включает обе стороны + downward seam frozen WCAG 2.1 (2018) EOTF `0.03928`. Directed-search guard + ограничивает область бисекции; первый passing state и точная минимальная alpha + не заявляются. +- Glow alpha больше не округляется независимо от выбранного sRGB8-state: + `alphaCss` round-trip восстанавливает ту же binary64 alpha и тот же композит. +- Нетривиальный stable CAM16 target/max-site без sound error bound больше не + получает правдоподобный platform-selected verdict: результат typed + `Indeterminate`. +- Exact stable no-op больше не маркируется как CAM16-selected verdict: + `selectionDiagnosticProfile` равен `null`. Полный semantic result отдельно + сохраняет non-null `appearanceDiagnosticProfile`, потому что сообщает + CAM16-derived `coreAchievedDj`, и versioned `layerRecipeProfile`. +- `BackdropBox::try_new` различает reversed, non-finite и out-of-range bounds + typed ошибками без swap, clamp или debug-паники. +- Core-generated Glow/Material postcondition failures отделены от + `InvalidInput`: WASM возвращает whole-call `internal_error`, UniFFI — + `IncompatibleCoreContract`, conformance generation останавливается до записи + артефакта вместо generic `"unreachable"` fallback. +- CSS namespace preflight теперь защищает основное имя каждого client-owned + токена, включая `Zero` и алиасы на него. Производные ключи Glow (`-core`, + `-alpha`) и Material (`-01`, `-02`) больше не могут молча записать цвет в + `cssVar` роли с `kind: "none"`; конфликтующий конфиг отклоняется до резолва. +- Устаревший wasm32-only muddiness snapshot (`olive > 0.80`) больше не является + мёртвым `#[test]`: реальный headless-browser gate проверяет публичный WASM- + метод по единственному committed conformance corpus без второго semantic + threshold или hand-written score. +- Исторические API `muddiness` / `drab` / `n_pure` и их conformance corpus + сохраняют численную совместимость, но явно помещены в quarantine как frozen + experimental compatibility proxy / research coordinate. Это не + observer-validated human cleanliness verdict и не сигнал базового compiler / + adaptive runtime; отклонённый provenance констант зафиксирован в inventory + под владельцами #231 / #242. + +### Compatibility evidence + +- Explicit `legacy-platform-dependent-v1` сохраняет прежний CAM16/libm path и + CSS-эмиссию, но не маркируется stable numerical guarantee. +- В закреплённом Lab UI corpus изменились ровно 24 Glow alpha-листа + `resolveVars` (4 ключа на 6 записях); 1376 пар `(lc, wcag)` recheck остались + байт-идентичны. Это ограниченное свидетельство для pinned corpus, не + универсальная гарантия произвольного клиента. +- После перехода с rational-wall midpoint на actual binary64 partition внутри + тех же 24 листов скорректированы 17 alpha-строк; в pinned corpus их point- + композиты и все остальные vars не изменились. + +### Known limits + +- Для нетривиального CAM16 target/max-выбора sound cross-runtime bound пока не + установлен; `stable-v1` намеренно возвращает `Indeterminate`. +- Exact point-композит не является сертификатом browser color-management, + дисплея/HDR или пространственного blur/overlap-эффекта. +- Material-alpha bisection характеризует повторно проверенный fail/pass bracket + в указанном numerical profile; это не proof глобальной монотонности, первого + passing state, точной минимальной alpha, predecessor или sound cross-runtime + bound. Профиль фиксирует original WCAG 2.1 (2018) split `0.03928`; текущая + формула W3C с `0.04045` требует отдельной версионированной миграции (#284). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..72275226 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1463 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "labcolors-conformance" +version = "0.2.0" +dependencies = [ + "labcolors-core", + "serde", + "serde_json", +] + +[[package]] +name = "labcolors-core" +version = "0.2.0" +dependencies = [ + "criterion", + "pretty_assertions", + "proptest", + "serial_test", + "tempfile", +] + +[[package]] +name = "labcolors-ffi" +version = "0.2.0" +dependencies = [ + "labcolors-core", + "thiserror", + "uniffi", +] + +[[package]] +name = "labcolors-preview" +version = "0.2.0" +dependencies = [ + "labcolors-core", +] + +[[package]] +name = "labcolors-wasm" +version = "0.2.0" +dependencies = [ + "console_error_panic_hook", + "criterion", + "js-sys", + "labcolors-conformance", + "labcolors-core", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "psychophysics" +version = "0.2.0" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uniffi" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" +dependencies = [ + "anyhow", + "camino", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" +dependencies = [ + "anyhow", + "askama", + "camino", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index abc070c0..e64e7a0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,11 @@ members = ["crates/*", "experiments/psychophysics"] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["Lab Pics "] license = "MIT" -repository = "https://github.com/lemone112/lab-colors" +repository = "https://github.com/Labpics-Team/lab-colors" rust-version = "1.85" # Centralised dependency pins. `labcolors-core` stays zero-RUNTIME-deps (issue #29): diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bc25dfbe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lab Pics + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fa30a3be..9e59c5f8 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,19 @@ NamedRoleTable - **Контекстный resolve.** Одна таблица решается заново для переданного локального фона и темы. - **Специализированные зависимости.** Например, `PairLabel` решается против эмитированного композита своей `PairFill`, а не против фона страницы. - **Несколько видов результата.** Роль может вернуть solid, translucent, material, точечный glow, явное отсутствие значения или недостижимость. +- **Версионированные численные свидетельства.** Exact encoded-sRGB8 + source-over/screen операции несут проверяемый профиль и `bit-exact` + сертификат; Glow требует явный decision profile и может завершиться + типизированным `Indeterminate` без CSS fallback. - **Непрерывные семейства.** `ColorCurve` и реализации `NeutralCurve`/`AccentCurve` доступны как низкоуровневые вычислительные примитивы. - **Браузерное применение.** `applyTheme`, `watchTheme`, `adaptTheme` и `effectiveBackground` связывают результат WASM с локальной областью DOM. ## Что не следует приписывать текущей реализации - Произвольный generic dependency graph и совместный SCC-solver ещё не являются публичным API. Сейчас есть resolve всей таблицы и отдельные специализированные пути зависимостей. -- Текущие решения на основе CAM16, CAM16-UCS, Oklab и LPC не доказаны как битово-точные на всех средах выполнения и платформах. +- Legacy-решения на основе CAM16, CAM16-UCS, Oklab и LPC не доказаны как + битово-точные на всех средах выполнения и платформах. Exact-гарантия + распространяется только на явно зарегистрированные конечные операции. - Обход DOM не является измерением фактически нарисованных пикселей браузера. - Точечные Glow и Material не являются сертификатом blur, пространственного поля, HDR или физического дисплея. - Автоматический выбор человечески «лучшего», «чистого», «похожего на бренд» или культурно правильного цвета не является частью обязательного базового resolve. @@ -157,11 +163,16 @@ CSS-строка, CSSOM и обход DOM сами по себе не повыш | Класс | Текущий смысл | |---|---| | **Поддерживаемый точечный путь sRGB** | encoded sRGB input/output, клиентский конфиг, resolve всей таблицы и специализированные рецепты в заявленной версии пакета | +| **Exact encoded-sRGB8 операции** | конечные source-over/screen композиторы, выбранная binary64 alpha и её канонический CSS round-trip; сертификат относится к point-reference, не к renderer/display | +| **Stable Glow decision** | exact point-no-op даёт `Determinate` / `bit-exact` без CAM16-профиля; нетривиальный target/max без sound bound даёт typed `Indeterminate`, не platform-selected fallback | | **Унаследованное платформенно охарактеризованное поведение** | target-driven CAM16/CAM16-UCS/Oklab/LPC, neutral/accent/sentiment policies и связанные поиски по `f64`; они поддерживают совместимость, но не дают cross-runtime bit-exact guarantee | | **Точность в отдельном эталонном профиле** | только операции, для которых конкретный release объявляет эталонный профиль и проверяемый конечный контракт | | **Явно не поддержано как стабильная гарантия** | Display-P3 solving, HDR/PQ/HLG, пространственное поле Glow/Material, индивидуальное восприятие, неизвестный browser/display pipeline | -До появления машинно читаемого численного профиля нельзя повышать legacy-результат до `BitExact`, `ProvenOptimal` или `ProvenInfeasible` только потому, что тесты на одной платформе зелёные. +Машинно читаемый реестр численных sites и их runtime-матрица входят в +conformance manifest. Он не повышает незарегистрированный или explicit legacy +результат до `BitExact`, `ProvenOptimal` или `ProvenInfeasible` только потому, +что тесты на одной платформе зелёные. ## Источники и производные значения @@ -226,7 +237,11 @@ anchors ### `adaptTheme` -Текущий контроллер является унаследованным механизмом совместимости. Он не должен описываться как уже доказанная гарантия нормативного floor на каждом промежуточном кадре и на всех образцах. +Текущий контроллер является охарактеризованным механизмом совместимости. Он +валидирует stable Glow evidence и селективно перерешивает роль при переходе +между exact-no-op и `Indeterminate`, не ломая уже идущую цветовую анимацию. Это +не универсальная гарантия нормативного floor на каждом промежуточном кадре и +на всех возможных образцах. Нормативный целевой контракт строже: @@ -264,6 +279,9 @@ anchors - [Научный whitepaper](docs/whitepaper.md) - [Реестр коэффициентов и policies](docs/empirical-inventory.md) - [Правила именования и конформанса](docs/NAMING.md) +- [Conformance и numerical registry](conformance/README.md) +- [Миграция exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md) +- [Changelog](CHANGELOG.md) Агент без контекста читает: @@ -272,9 +290,9 @@ AGENTS.md → Issue #276 → Issue #228 → Issue #248 -→ Issue #281 -→ активный Issue / PR → актуальный main +→ активный PR +→ Owner Issue текущего correctness-root ``` Roadmap, текущий SHA и активный PR хранятся только в Issues. @@ -315,9 +333,9 @@ cargo tree -p labcolors-core --edges=no-dev ```sh cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked ``` Изменения browser/WASM/public wire contract дополнительно требуют сборки пакета, consumer-smoke, JS/browser tests и platform conformance. Обязательные gates определяются workflow-файлами и acceptance конкретного Issue. diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 33ea9407..4f872f86 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -1,10 +1,12 @@ // swift-tools-version:5.9 // SwiftPM-пакет нативного биндинга labcolors. Линкует СТАТИЧЕСКУЮ Rust-библиотеку // (`liblabcolors.a`) + сгенерированные UniFFI Swift-биндинги, и прогоняет -// conformance-пак против рантайм-ядра. Собирается ТОЛЬКО на macOS в CI: файлы -// в `Sources/LabColors` и `Sources/labcolorsFFI` генерирует uniffi-bindgen -// ПЕРЕД `swift test` (см. .github/workflows/native-conformance.yml). В репозитории -// эти каталоги несут лишь .gitkeep — сгенерированное не коммитится. +// conformance-пак против рантайм-ядра. Активный CI-гейт собирает пакет в pinned +// Swift-контейнере на Linux x86_64; ручной macOS/arm64 path не является +// достигнутой аттестацией. Файлы в `Sources/LabColors` и +// `Sources/labcolorsFFI` генерирует uniffi-bindgen ПЕРЕД `swift test` (см. +// .github/workflows/native-conformance.yml). В репозитории эти каталоги несут +// лишь .gitkeep — сгенерированное не коммитится. import PackageDescription let package = Package( @@ -23,8 +25,9 @@ let package = Package( dependencies: ["labcolorsFFI"], path: "Sources/LabColors" ), - // Conformance-тесты: грузят закоммиченные векторы из ../../conformance и - // сверяют с выходом FFI. Линкуют статическую Rust-библиотеку из + // Conformance-тесты: грузят закоммиченные векторы из ../../conformance, + // сверяют их с выходом FFI и отдельно проверяют типизированный Glow + // decision contract. Линкуют статическую Rust-библиотеку из // target/debug (путь относительно корня пакета bindings/swift). .testTarget( name: "LabColorsConformanceTests", diff --git a/bindings/swift/README.md b/bindings/swift/README.md index d253d042..4e306709 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -1,9 +1,20 @@ # Нативный биндинг labcolors (Swift / UniFFI) -Доказательство **динамического рантайм-ядра** на Apple-платформах: Swift зовёт -ядро Rust (`labcolors-core`) В РАНТАЙМЕ через UniFFI, а не сериализует токены на -сборке. Экспортируется рантайм-контраст-ядро (см. `crates/labcolors-ffi`): -контраст, резолв, лестницы, подложка→α, мутность. +Swift-поверхность динамического Rust-ядра через UniFFI: сгенерированный Swift +вызывает `labcolors-core` в runtime, а не сериализует токены на сборке. +Экспортируется рантайм-контраст-ядро (см. `crates/labcolors-ffi`): контраст, +резолв, лестницы, подложка→α, legacy-координата `muddiness` и low-level Glow +point decision с обязательным numerical profile. + +Исторически названная `muddiness` поверхность — это +`experimental compatibility proxy`: она сохраняет прежний числовой API и его +conformance-векторы, но не +является валидированным на наблюдателях человеческим вердиктом clean/dirty и не +должна использоваться как production decision. Legacy-идентификатор сохранён +только для совместимости. + +Текущее исполняемое доказательство этой поверхности — pinned Swift-контейнер на +Linux x86_64. Оно не является аттестацией Apple ABI, macOS/arm64 или iOS. ## Что здесь @@ -19,26 +30,62 @@ Платные GitHub-hosted macOS-раннеры исключены владельцем — Swift валидируется в официальном **swift-контейнере на Linux x86_64** (тот же UniFFI-биндинг, тот же -пак, ядро под `x86_64-unknown-linux-gnu`; кросс-платформа держится толерантностью -`DRIFT_TOL`). Единый скрипт — `ci/run-conformance.sh` — используют и локальный -прогон, и self-hosted CI-джоба (`.github/workflows/native-conformance.yml`). +пак, ядро под `x86_64-unknown-linux-gnu`). `DRIFT_TOL` задаёт правило сравнения, +но не заменяет прогоны на других платформах. Единый скрипт — +`ci/run-conformance.sh` — используют и локальный прогон, и self-hosted CI-джоба +(`.github/workflows/native-conformance.yml`). Локально (нужен Docker): ```sh # из корня репозитория -docker run --rm -v "$PWD":/src:ro swift:6.1 \ +docker run --rm -v "$PWD":/src:ro \ + swift:6.1.3@sha256:e1cdaf7ddc9de37d8561da7a260535236694fca8c1b67d3129d47d8b180a9394 \ bash /src/bindings/swift/ci/run-conformance.sh ``` -Аттестация последнего зелёного прогона (числа, версии, платформа): -`docs/conformance/local-swift-attestation.md`. На нативном macOS (если появится -БЕСПЛАТНЫЙ раннер) — джоба `swift-conformance-macos-reference` (ручной -`workflow_dispatch`) в том же workflow. +Историческая аттестация pack `1.0.0` (числа, версии, платформа): +`docs/conformance/local-swift-attestation.md`; она явно superseded для текущего +pack `2.0.0` и не подменяет новый CI-прогон. Нативный macOS/arm64 path сейчас +представлен только ручной reference-джобой +`swift-conformance-macos-reference` (`workflow_dispatch`): это **не gate** PR, +`main` или release. Полная platform/runtime-матрица остаётся отдельной работой +в [issue #258](https://github.com/Labpics-Team/lab-colors/issues/258). ## Толерантность conformance -Числовые поля conformant в пределах `DRIFT_TOL = 1e-6` (канон ядра, `lut.rs`): -байт-точность f64 кросс-платформенно невозможна (libm-шум `powf`/`atan2`/`ln` -~1e-13). Композит-hex — чистая IEEE-алгебра, точен; solve-hex — квантование -трансцендентного резолва, ±1 LSB на канал. +Числовые поля зафиксированных семейств conformant в пределах +`DRIFT_TOL = 1e-6` (SSOT пака: +`crates/labcolors-conformance/src/lib.rs`): для зависимых от libm путей +(`powf`/`atan2`/`ln`) битовая идентичность f64 между runtime не гарантируется; +реализации могут расходиться на несколько ULP (~1e-13). +Композит-hex точен относительно объявленного encoded-sRGB8 operation profile, +который фиксирует порядок IEEE binary64 операций и квантование; исполняемое +Swift/UniFFI evidence сейчас ограничено описанным выше pinned Linux x86_64 +runtime. Solve-hex — квантование трансцендентного резолва, ±1 LSB на канал. + +Glow проверяется другим контрактом: `stable-v1` обязан вернуть типизированный +`Indeterminate` (`site_id` + неразделимое typed `evidence`), если доказанной +границы нет; прежний CAM16/libm-dependent путь доступен только как явный +`legacy-platform-dependent-v1`. Нативный гейт не заявляет CAM16 bit parity: +`bit-exact` относится только к encoded-sRGB8 screen-композиту и его certificate. +Native output кодирует provenance самим вариантом `GlowPointDecision`: +`stableExactNoop`, `legacyReached`, `legacyUnreachable` либо `indeterminate`. +Отдельных `decisionProfile`, `decisionGuarantee`, `targetStatus` и +`selectionDiagnosticProfile` в output больше нет — поэтому клиент физически не +может собрать «stable + legacy guarantee» или другую невозможную комбинацию. +Requested `GlowDecisionProfile` по-прежнему обязателен как input функции; +`GlowPointValue` хранит только общий exact screen-composite payload и его +composite profile/guarantee. + +`stableExactNoop` сам означает byte-exact недостижимость без appearance +selection; `legacyReached` / `legacyUnreachable` сами несут CAM16/libm legacy +provenance и target outcome. `indeterminate` является stable отказом выбрать +состояние без sound bound. Typed `NumericalIndeterminacy.intervalOverlap` +остаётся законным outward evidence неопределённости. + +Граница самостоятельно валидирует tint, background и конечный `targetDj > 0`: +только эти ошибки становятся `ColorError.InvalidGlowRequest`. Ошибка core после +успешной валидации, неизвестный численный variant или новый `Unreachable` +считаются `ColorError.IncompatibleCoreContract`; adapter не выдаёт им +выдуманный fallback-code. diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index 14fff346..1339047b 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -5,10 +5,13 @@ import XCTest /// Conformance-прогон нативного (Swift/UniFFI) биндинга против закоммиченного /// пака `conformance/vectors/*.json`. Доказывает: рантайм-ядро Rust, вызванное -/// с Swift-стороны, воспроизводит канон. Числовые поля сверяются в пределах -/// `driftTol` (= `DRIFT_TOL` ядра, 1e-6: кросс-платформенный libm-шум ~1e-13, -/// реальный дрейф — целые единицы). Композит-hex — чистая IEEE-алгебра, точен; -/// solve-hex — квантование трансцендентного резолва, допускается ±1 LSD/канал. +/// с Swift-стороны, воспроизводит зафиксированный conformance-пак. Числовые +/// поля сверяются в пределах `driftTol` (= `DRIFT_TOL` conformance-пака, 1e-6: +/// кросс-платформенный libm-шум ~1e-13, реальный дрейф — целые единицы). +/// Композит-hex — чистая IEEE-алгебра, точен; solve-hex — квантование +/// трансцендентного резолва, допускается ±1 LSD/канал. +/// Glow-проверка ниже намеренно НЕ утверждает bit-parity CAM16: она проверяет +/// типизированный класс решения и точный certificate композитинга отдельно. final class ConformanceTests: XCTestCase { static let driftTol = 1e-6 @@ -60,6 +63,25 @@ final class ConformanceTests: XCTestCase { } } + /// Independent encoded-sRGB8 screen oracle. It intentionally does not call + /// the source-over FFI primitive: on black those operators coincide and + /// would make the Glow certificate assertion vacuous. + func screenComposite(tint: String, alpha: Double, background: String) -> String { + let glow = channels(tint) + let bg = channels(background) + var result: [Int] = [] + result.reserveCapacity(3) + for channel in 0..<3 { + let backgroundChannel = Double(bg[channel]) + let glowChannel = Double(glow[channel]) + let backgroundHeadroom = Double(255 - bg[channel]) + let contribution = alpha * glowChannel * backgroundHeadroom / 255.0 + let rounded = Int(floor(backgroundChannel + contribution + 0.5)) + result.append(rounded) + } + return String(format: "#%02X%02X%02X", result[0], result[1], result[2]) + } + /// Квантованный цвет conformant в пределах ±1 LSB на канал (кросс-платформенно). func assertHexWithinOne(_ a: String, _ b: String, _ ctx: String) { let ca = channels(a), cb = channels(b) @@ -120,6 +142,163 @@ final class ConformanceTests: XCTestCase { } } + // MARK: - Low-level Glow decision contract + + func testGeneratedGlowSurfaceContainsOnlyAtomicProvenanceVariants() throws { + var packageRoot = URL(fileURLWithPath: #filePath) + for _ in 0..<3 { packageRoot = packageRoot.deletingLastPathComponent() } + let generatedSource = packageRoot + .appendingPathComponent("Sources") + .appendingPathComponent("LabColors") + .appendingPathComponent("labcolors.swift") + let source = try String(contentsOf: generatedSource, encoding: .utf8) + + // Positive cases делают negative API-проверки невакуумными: читается + // именно сгенерированная algebraic Glow surface, а не пустой/чужой файл. + XCTAssertTrue(source.contains("public enum GlowPointDecision")) + XCTAssertTrue(source.contains("case stableExactNoop(")) + XCTAssertTrue(source.contains("case legacyReached(")) + XCTAssertTrue(source.contains("case legacyUnreachable(")) + XCTAssertTrue(source.contains("case indeterminate(")) + XCTAssertTrue(source.contains("public enum GlowDecisionProfile")) + XCTAssertTrue(source.contains("case IncompatibleCoreContract(")) + XCTAssertFalse(source.contains("case determinate(")) + XCTAssertFalse(source.contains("public enum GlowDecisionGuarantee")) + XCTAssertFalse(source.contains("public enum GlowTargetStatus")) + XCTAssertFalse(source.contains("public enum GlowDiagnosticProfile")) + } + + func testInvalidPublicGlowInputsKeepPublicInputErrorTaxonomy() { + let invalidInputs: [(String, String, Double)] = [ + ("not-a-color", "#000000", 2.3006), + ("#C0B2FA", "not-a-color", 2.3006), + ("#C0B2FA", "#000000", 0.0), + ("#C0B2FA", "#000000", -1.0), + ("#C0B2FA", "#000000", .nan), + ("#C0B2FA", "#000000", .infinity), + ] + for (tint, background, targetDj) in invalidInputs { + XCTAssertThrowsError(try solveGlowPoint( + tint: tint, + background: background, + targetDj: targetDj, + theme: .light, + profile: .stableV1 + )) { error in + guard case let ColorError.InvalidGlowRequest(reason) = error else { + return XCTFail( + "public input error не должен становиться adapter incompatibility: \(error)") + } + XCTAssertFalse(reason.isEmpty) + } + } + } + + func testGlowInputProfilesRemainExplicitAndOutputProvenanceIsAtomic() throws { + let tint = "#C0B2FA" + let background = "#101012" + let targetDj = 2.3006 + + // Independent exact-rational anti-vacuum probe: at alpha 1/2 the two + // encoded operators differ by two bytes in R/G. Keeping this fixture + // independent from the solver-selected alpha prevents an accidental + // same-hex quantisation from weakening the oracle. + let screenProbe = screenComposite(tint: tint, alpha: 0.5, background: background) + let sourceOverProbe = try composite(tint: tint, alpha: 0.5, bg: background) + XCTAssertEqual(screenProbe, "#6A6386") + XCTAssertEqual(sourceOverProbe, "#686186") + XCTAssertNotEqual(screenProbe, sourceOverProbe) + + let stable = try solveGlowPoint( + tint: tint, + background: background, + targetDj: targetDj, + theme: .light, + profile: .stableV1) + switch stable { + case let .indeterminate(siteId, evidence): + XCTAssertEqual(siteId, .glowTargetOrMaximumV1) + guard case .soundBoundUnavailable = evidence else { + return XCTFail("stable-v1 обязан вернуть typed unavailable-bound evidence") + } + case .stableExactNoop, .legacyReached, .legacyUnreachable: + XCTFail("stable-v1 не должен выбирать состояние без sound bound") + } + + let legacy = try solveGlowPoint( + tint: tint, + background: background, + targetDj: targetDj, + theme: .light, + profile: .legacyPlatformDependentV1) + switch legacy { + case let .legacyReached(value): + XCTAssertEqual(value.compositeProfile, .encodedSrgb8ScreenV1) + XCTAssertEqual(value.compositeGuarantee, .bitExact) + XCTAssertEqual(value.targetDj, targetDj) + let recomposite = screenComposite( + tint: tint, alpha: value.alpha, background: background) + XCTAssertEqual( + recomposite, + value.compositeHex, + "bit-exact относится к композитору, не к CAM16 decision") + case .stableExactNoop, .legacyUnreachable, .indeterminate: + XCTFail("explicit legacy reached fixture обязан вернуть atomic legacyReached") + } + + func assertStableNoop(tint: String, background: String, composite expected: String) throws { + let decision = try solveGlowPoint( + tint: tint, + background: background, + targetDj: targetDj, + theme: .light, + profile: .stableV1) + switch decision { + case let .stableExactNoop(value): + XCTAssertEqual(value.compositeProfile, .encodedSrgb8ScreenV1) + XCTAssertEqual(value.compositeGuarantee, .bitExact) + XCTAssertEqual(value.achievedDj, 0.0) + XCTAssertEqual(value.compositeHex, expected) + case .legacyReached, .legacyUnreachable, .indeterminate: + XCTFail("exact screen no-op обязан вернуть atomic stableExactNoop") + } + } + + try assertStableNoop(tint: tint, background: "#FFFFFF", composite: "#FFFFFF") + try assertStableNoop(tint: "#010000", background: "#FE0000", composite: "#FE0000") + + let legacyUnreachable = try solveGlowPoint( + tint: tint, + background: "#FFFFFF", + targetDj: targetDj, + theme: .light, + profile: .legacyPlatformDependentV1) + switch legacyUnreachable { + case let .legacyUnreachable(value): + XCTAssertEqual(value.compositeProfile, .encodedSrgb8ScreenV1) + XCTAssertEqual(value.compositeGuarantee, .bitExact) + XCTAssertEqual(value.compositeHex, "#FFFFFF") + case .stableExactNoop, .legacyReached, .indeterminate: + XCTFail("explicit legacy no-op обязан вернуть atomic legacyUnreachable") + } + + let crossing = try solveGlowPoint( + tint: "#800000", + background: "#FE0000", + targetDj: targetDj, + theme: .light, + profile: .stableV1) + switch crossing { + case let .indeterminate(siteId, evidence): + XCTAssertEqual(siteId, .glowTargetOrMaximumV1) + guard case .soundBoundUnavailable = evidence else { + return XCTFail("first crossing обязан сохранить typed unavailable-bound evidence") + } + case .stableExactNoop, .legacyReached, .legacyUnreachable: + XCTFail("#800000 над #FE0000 пересекает первый half-LSB wall") + } + } + // MARK: - Семейство: резолв (снапшоты токенов) func testSolve() throws { @@ -150,7 +329,7 @@ final class ConformanceTests: XCTestCase { } } - // MARK: - Семейство: мутность + // MARK: - Семейство: legacy proxy coordinate func testMuddiness() throws { let vectors = try load("muddiness.json", as: [MuddinessVec].self) diff --git a/bindings/swift/ci/run-conformance.sh b/bindings/swift/ci/run-conformance.sh index 46cec3ed..3cd45abe 100644 --- a/bindings/swift/ci/run-conformance.sh +++ b/bindings/swift/ci/run-conformance.sh @@ -8,9 +8,13 @@ # # Ожидает исходники репозитория в /src (read-only bind-mount); собирает в /work. # Запуск: -# docker run --rm -v "":/src:ro swift:6.1 bash /src/bindings/swift/ci/run-conformance.sh +# docker run --rm -v "":/src:ro \ +# swift:6.1.3@sha256:e1cdaf7ddc9de37d8561da7a260535236694fca8c1b67d3129d47d8b180a9394 \ +# bash /src/bindings/swift/ci/run-conformance.sh set -euo pipefail +readonly RUST_TOOLCHAIN=1.96.0 + echo "==================== ПЛАТФОРМА / SWIFT ====================" uname -a swift --version @@ -19,11 +23,12 @@ echo "==================== УСТАНОВКА RUST ====================" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl build-essential pkg-config >/dev/null -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- \ + -y --default-toolchain "$RUST_TOOLCHAIN" --profile minimal # shellcheck disable=SC1091 source "$HOME/.cargo/env" -rustc --version -cargo --version +rustc +"$RUST_TOOLCHAIN" --version +cargo +"$RUST_TOOLCHAIN" --version echo "==================== КОПИЯ ИСХОДНИКОВ (без target/.git) ====================" mkdir -p /work @@ -35,14 +40,14 @@ tar -C /src \ cd /work echo "==================== СБОРКА labcolors-ffi (Linux) + bindgen ====================" -cargo build -p labcolors-ffi --features cli +cargo +"$RUST_TOOLCHAIN" build -p labcolors-ffi --features cli --locked ls -la target/debug/liblabcolors.* echo "==================== РАННЕР-РЕФЕРЕНС (ядро на Linux воспроизводит пак) ====================" -cargo test -p labcolors-conformance -p labcolors-ffi +cargo +"$RUST_TOOLCHAIN" test -p labcolors-conformance -p labcolors-ffi --locked echo "==================== ГЕНЕРАЦИЯ SWIFT-БИНДИНГОВ (из .so) ====================" -cargo run -p labcolors-ffi --features cli --bin uniffi-bindgen -- \ +cargo +"$RUST_TOOLCHAIN" run -p labcolors-ffi --features cli --bin uniffi-bindgen --locked -- \ generate --library target/debug/liblabcolors.so \ --language swift --out-dir bindings/swift/generated diff --git a/conformance/README.md b/conformance/README.md index a81ed225..1a80b809 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,24 +1,25 @@ # Conformance-пак labcolors -Платформо-нейтральный контракт равенства всех биндингов движка. Ядро -(`labcolors-core`) детерминировано, но у него теперь несколько поверхностей — -WASM (`@labpics/colors`) и нативная (Swift через UniFFI). Пак — исполняемое -доказательство, что **каждая поверхность отдаёт одни и те же числа**: набор -детерминированных векторов «вход → канонический выход». Биндинг, воспроизводящий -их в пределах толерантности, называется **conformant**. +Платформо-нейтральный контракт проверки биндингов движка. Пак задаёт набор +векторов «вход → канонический выход» и правила сравнения; конкретная поверхность +называется **conformant** только после того, как действительно воспроизвела +каждый вектор заявленной версии в зафиксированной runtime-матрице. Наличие пака +само по себе не доказывает, что все существующие поверхности уже прошли его +целиком. -Это опора динамической архитектуры: рантайм-ядро на каждой платформе обязано -считать идентично; запечённые build-time токены — лишь оптимизация, и обязаны -совпадать с рантаймом на этих же векторах. +Это опора динамической архитектуры: рантайм-ядро на каждой заявленной платформе +обязано пройти правила сравнения пака; запечённые build-time токены — лишь +оптимизация и проверяются против того же контракта. ## Версионирование -- **Версия пака** (`manifest.packVersion`, сейчас `1.0.0`) — семантическая +- **Версия пака** (`manifest.packVersion`, сейчас `2.0.0`) — семантическая версия СХЕМЫ и состава векторов. -- **Версия ядра** (`manifest.coreVersion`) — версия `labcolors-core`, из канона - которой сгенерированы значения. Пак действителен ровно для этой версии ядра; - при легитимной смене канона (значения якорей/ручек, формулы) генератор - перегенерирует векторы и `coreVersion` сдвигается. +- **Версия ядра** (`manifest.coreVersion`, для этого пака `0.2.0`) — версия + `labcolors-core`, из канона которой сгенерированы значения. Пак действителен + ровно для этой версии ядра; при легитимной смене канона (значения + якорей/ручек, формулы) генератор перегенерирует векторы и `coreVersion` + сдвигается. - **Дайджест** (`manifest.packDigest`) — FNV-1a-32 над сырыми байтами семейств (в порядке `contrasts, ladders, alpha, solve, muddiness`). Отпечаток КОНКРЕТНОГО закоммиченного артефакта. Зависит от платформы генерации (последний @@ -33,8 +34,13 @@ WASM (`@labpics/colors`) и нативная (Swift через UniFFI). Пак | `ladders.json` | альфы позиции лестницы | `{position, alphaLight, alphaDark}` | | `alpha.json` | подложка→α | `{tint, alpha, bg, composite, minAlpha}` | | `solve.json` | резолв контракта | `{bg, contract, theme, outcome}` | -| `muddiness.json` | мутность цвета | `{hex, score}` | -| `manifest.json` | метаданные | `{packVersion, coreVersion, packDigest, counts}` | +| `muddiness.json` | замороженная legacy-координата `muddiness` | `{hex, score}` | +| `manifest.json` | метаданные и реестр мигрированных numerical sites | `{packVersion, coreVersion, packDigest, counts, numericalSites}` | + +`muddiness.json` — это `experimental compatibility proxy`: corpus доказывает +воспроизводимость исторического числового API, но не валидированный на +наблюдателях человеческий вердикт clean/dirty и не пригодность для production +decision. Legacy-идентификаторы сохранены только для совместимости. - `theme` — kebab-ключ: `light` \| `dark` \| `light-ic` \| `dark-ic`. - `contract` (в `solve`): `{kind:"text", lc}` \| `{kind:"ui", lc}` \| @@ -44,6 +50,14 @@ WASM (`@labpics/colors`) и нативная (Swift через UniFFI). Пак - `code` недостижимости — стабильный словарь, общий для всех биндингов: `below_contrast_floor`, `exceeds_range`, `quantization_gap`, `floor_unreachable`, `polarity_mismatch`, `gamut_unsupported`, `invalid_input`. +- `alpha.json` в pack `2.0.0` обязательно содержит точный byte-reference + half-tie `#C0B2FA @ 0.122` над `#000000` → `#17161F`. Это mutation-killer + старого пути `(byte/255) · alpha · 255`, который выбирал соседний LSB. +- `manifest.numericalSites` генерируется из core-owned + `numerical_registry_v1()`. Это реестр **уже мигрированных** branch-sensitive + sites, а не утверждение, что проаудированы все исторические `f64`-ветвления. + Сейчас в нём только `glow-target-or-maximum-v1`; полнота аудита остаётся в + scope #291. Словарь **позиций лестницы** (не ролей): `label-*`, `fill-*`, `border-*`, `focus-ring`, `glow`, `skeleton-*`, `neutral-fill-*`, `neutral-border-*`, @@ -56,17 +70,25 @@ labels (канон labui): роли `icon` в словаре нет. совпадает с каноном по этим правилам: - **Числовые поля** (`lc`, `wcagRatio`, `score`, `alpha`, `minAlpha`, `alpha*`) — - в пределах `DRIFT_TOL = 1e-6` (абсолютная). Байт-точность f64 кросс-платформенно - НЕВОЗМОЖНА: `powf`/`atan2`/`ln` в libm разных ОС/архитектур расходятся на - несколько ULP (~1e-13); реальный дрейф (не тот surround, опечатка в матрице, - путаница единиц) сдвигает значения на целые единицы — на порядки выше - толерантности. Значение `1e-6` — канон ядра (`labcolors-core/src/lut.rs`, - `DRIFT_TOL`). -- **`composite` (hex)** — ТОЧНО. Композит — чистая IEEE-алгебра кодированного - sRGB (умножение/сложение) плюс квантование: бит-идентичен на всех платформах. + в пределах `DRIFT_TOL = 1e-6` (абсолютная). Для зависимых от libm путей + (`powf`/`atan2`/`ln`) битовая идентичность f64 между платформами не + гарантируется: реализации разных ОС/архитектур могут расходиться на несколько + ULP (~1e-13); реальный дрейф (не тот surround, опечатка в матрице, путаница + единиц) сдвигает значения на целые единицы — на порядки выше толерантности. + Единственный источник этого значения для пака — + `crates/labcolors-conformance/src/lib.rs` (`DRIFT_TOL`). +- **`composite` (hex)** — ТОЧНО относительно объявленного encoded-sRGB8 + operation profile: фиксированный порядок IEEE binary64 + умножения/сложения и квантования задаёт byte-reference. Это контракт профиля, + а не заявление о доказанной идентичности произвольной платформы; исполняемое + evidence ограничено фактически аттестованной runtime-матрицей ниже. - **`solve.outcome.hex`** — в пределах **±1 LSB на канал**. Это квантование трансцендентного резолва: у границы 8-бит-ячейки libm-шум может качнуть результат на один шаг. + +Внутренняя ошибка core и неизвестный forward-вариант `Unreachable` не являются +solve-векторами: `Pack::generate()` возвращает `PackGenerationError` и не пишет +правдоподобный `{kind:"unreachable"}` fallback в сертификационный артефакт. - **Строки/enum/bool** (`theme`, `position`, `code`, `floorOverride`, `kind`) — ТОЧНО. @@ -76,9 +98,19 @@ labels (канон labui): роли `icon` в словаре нет. (`tests/reference_runner.rs`). Раннер — CI-гейт: ядро воспроизводит каждый вектор в пределах толерантности, дайджест сходится с сырыми байтами, а опубликованные WCAG-якоря (21:1, граница `#767676`) держатся. Раннер входит в -`cargo test --workspace` (Linux-ферма, ci.yml) и дополнительно гоняется на -macOS/arm64 (native-conformance.yml) — тот же пак воспроизводится на трёх -платформах. +`cargo test --workspace` на Linux x86_64. Активный Swift/UniFFI gate также +прогоняет все пять семейств пака в pinned Linux x86_64 container. + +Активный browser-gate теперь воспроизводит все 82 закоммиченных вектора внутри +фактического wasm32 core runtime и отдельно держит targeted parity-тесты +публичной JS-границы. Это доказывает wasm32-исполнение ядра против независимых +байтов пака, но ещё не прогоняет каждый вектор всех пяти семейств непосредственно +через публичный JS API. Поэтому полная conformance именно JS-поверхности текущего +пака пока не заявляется. В +`native-conformance.yml` сохранён ручной macOS/arm64 reference path, но он не +запускается на PR/push и не считается достигнутой аттестацией pack 2.0.0. +Полная runtime-матрица остаётся scope #258; допуск `DRIFT_TOL` задаёт правило +сравнения и не заменяет отсутствующий прогон. ## Регенерация diff --git a/conformance/vectors/alpha.json b/conformance/vectors/alpha.json index b4f84eee..c9b6008f 100644 --- a/conformance/vectors/alpha.json +++ b/conformance/vectors/alpha.json @@ -40,5 +40,12 @@ "bg": "#007AFF", "composite": "#80BDFF", "minAlpha": 1.0 + }, + { + "tint": "#C0B2FA", + "alpha": 0.122, + "bg": "#000000", + "composite": "#17161F", + "minAlpha": 0.9803921568627451 } ] diff --git a/conformance/vectors/manifest.json b/conformance/vectors/manifest.json index e7b2fbd8..f42bb82e 100644 --- a/conformance/vectors/manifest.json +++ b/conformance/vectors/manifest.json @@ -1,13 +1,30 @@ { - "packVersion": "1.0.0", - "coreVersion": "0.1.0", - "packDigest": "9fbfab4f", + "packVersion": "2.0.0", + "coreVersion": "0.2.0", + "packDigest": "64a68cbd", "counts": { "contrasts": 40, "ladders": 25, - "alpha": 6, + "alpha": 7, "solve": 6, "muddiness": 4, - "total": 81 - } + "total": 82 + }, + "numericalSites": [ + { + "siteId": "glow-target-or-maximum-v1", + "operations": "CAM16 forward powf; CAM16-UCS J-prime; abs; target >=; maximum ordering", + "domain": "encoded sRGB8 point screen states -> diagnostic CAM16-UCS delta J-prime", + "branchEffect": "first reached state versus global maximum and reached/unreachable status", + "stableOutcomes": [ + "bit-exact", + "indeterminate" + ], + "boundStatus": "unavailable", + "boundaryCorpus": "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", + "runtimeMatrix": "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", + "fallbackStatus": "none", + "legacyProfile": "legacy-platform-dependent-v1" + } + ] } diff --git a/conformance/vectors/muddiness.json b/conformance/vectors/muddiness.json index eab99b12..d4ab24e1 100644 --- a/conformance/vectors/muddiness.json +++ b/conformance/vectors/muddiness.json @@ -1,11 +1,11 @@ [ { "hex": "#6B6B2E", - "score": 0.40640071696097596 + "score": 0.40640071696097685 }, { "hex": "#808080", - "score": 0.0012598780845630739 + "score": 0.0012598780920495246 }, { "hex": "#007AFF", @@ -13,6 +13,6 @@ }, { "hex": "#8A7A50", - "score": 0.22811464489534145 + "score": 0.2281146448953413 } ] diff --git a/crates/labcolors-conformance/Cargo.toml b/crates/labcolors-conformance/Cargo.toml index f2545ba2..ffd4023e 100644 --- a/crates/labcolors-conformance/Cargo.toml +++ b/crates/labcolors-conformance/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "labcolors-conformance" +publish = false version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true description = "Платформо-нейтральный conformance-пак движка labcolors: детерминированные тест-векторы канона (лестницы, подложка→α, контрасты, резолв) плюс Rust-раннер-референс. Любой биндинг (WASM, Swift, …) обязан их воспроизвести." [dependencies] diff --git a/crates/labcolors-conformance/src/bin/gen.rs b/crates/labcolors-conformance/src/bin/gen.rs index bb8d9703..b86b50b7 100644 --- a/crates/labcolors-conformance/src/bin/gen.rs +++ b/crates/labcolors-conformance/src/bin/gen.rs @@ -24,11 +24,11 @@ fn vectors_dir() -> PathBuf { .join("vectors") } -fn main() -> std::io::Result<()> { +fn main() -> Result<(), Box> { let dir = vectors_dir(); std::fs::create_dir_all(&dir)?; - let pack = Pack::generate(); + let pack = Pack::generate()?; // Семейства. for (name, bytes) in pack.families() { diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 590a4849..17516943 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -2,13 +2,12 @@ //! //! # Что это и зачем //! -//! Ядро (`labcolors-core`) детерминировано: один вход → байт-идентичный выход. -//! Но у движка теперь НЕСКОЛЬКО поверхностей — WASM (`@labpics/colors`), а с -//! этой задачи и нативная (Swift через UniFFI). «Динамичность» архитектуры -//! (рантайм-ядро на каждой платформе) держится ровно до тех пор, пока КАЖДАЯ -//! поверхность отдаёт ОДНИ И ТЕ ЖЕ числа. Пак — исполняемый контракт этого -//! равенства: набор детерминированных векторов «вход → канонический выход», -//! которые обязан воспроизвести любой биндинг, чтобы называться conformant. +//! Ядро (`labcolors-core`) задаёт канонический выход, но не все его поля имеют +//! byte-exact cross-runtime guarantee: трансцендентные `f64` сравниваются по +//! [`DRIFT_TOL`], exact hex/enum — точно. У движка несколько поверхностей — WASM +//! (`@labpics/colors`) и Swift через UniFFI. Пак задаёт исполняемый контракт: +//! набор векторов «вход → канонический выход» и правила сравнения, которые +//! обязан пройти конкретный биндинг, прежде чем называться conformant. //! //! # Честность конструкции //! @@ -27,12 +26,19 @@ //! | `ladders.json` | позиция лестницы → (α_light, α_dark) | `LadderPosition::alpha_pair` | //! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` | -//! | `muddiness.json` | hex → мутность | `cleanliness::muddiness_from_hex` | -//! | `manifest.json` | версии пака/ядра, дайджест, счётчики | — | +//! | `muddiness.json` | hex → замороженная legacy-координата | `cleanliness::muddiness_from_hex` | +//! | `manifest.json` | версии, дайджест, счётчики, migrated numerical sites | `numerical_registry_v1` | +//! +//! `muddiness.json` фиксирует только воспроизводимость исторического числового +//! API. Это `experimental compatibility proxy`, а не валидированный на +//! наблюдателях человеческий вердикт clean/dirty и не production decision; +//! legacy-идентификаторы сохранены для совместимости. //! //! Версия пака ([`PACK_VERSION`]) привязана к версии ядра ([`core_version`]): //! при легитимной смене канона генератор перегенерирует векторы, а //! раннер-референс ловит любой дрейф. +//! `manifest.numericalSites` перечисляет только уже мигрированные typed-decision +//! sites; полнота аудита исторических `f64` branches остаётся в #291. use serde::{Deserialize, Serialize}; @@ -40,12 +46,12 @@ use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, Theme, ViewingConditions, - fnv1a_32, recheck_against, solve, + fnv1a_32, numerical_registry_v1, recheck_against, solve, }; /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или /// состава векторов; значения векторов при этом диктует канон ядра. -pub const PACK_VERSION: &str = "1.0.0"; +pub const PACK_VERSION: &str = "2.0.0"; /// Версия ядра, к которой привязан пак. Все крейты воркспейса делят одну версию /// (`version.workspace = true`), поэтому собственная `CARGO_PKG_VERSION` этого @@ -189,14 +195,16 @@ pub struct AlphaVector { } /// Тройки (тинт, α, фон) для альфа-алгебры: нейтральный тинт labui над светлым -/// и тёмным фоном на разных уровнях лестницы, плюс бренд. -const ALPHA_CASES: [(&str, f64, &str); 6] = [ +/// и тёмным фоном на разных уровнях лестницы, бренд и обязательный v2 half-tie +/// из ADR-0004, различающий byte-reference от старого нормализованного пути. +const ALPHA_CASES: [(&str, f64, &str); 7] = [ ("#787880", 0.2, "#FFFFFF"), // нейтральная заливка @20 на белом ("#787880", 0.122, "#FFFFFF"), // граница @12 на белом ("#787880", 0.361, "#101012"), // нейтральная заливка @36 на тёмном ("#007AFF", 0.122, "#FFFFFF"), // бренд-заливка @12 на белом ("#101012", 0.122, "#FFFFFF"), // тень @12 на белом ("#FFFFFF", 0.5, "#007AFF"), // белый полупрозрачный на бренде + ("#C0B2FA", 0.122, "#000000"), // half-tie: канал 250 × .122 = 30.5 → 31 ]; /// Дериватор альфа-векторов. @@ -288,22 +296,54 @@ pub enum SolveOutcome { }, } -/// Стабильный код недостижимости. Тождественен маппингу WASM-границы -/// (`labcolors-wasm/src/engine.rs`) — контракт имён общий для всех биндингов. -#[must_use] -pub fn unreachable_code(err: &labcolors_core::Unreachable) -> &'static str { +/// Ошибка построения conformance-пака. Генератор не имеет права превращать +/// внутренний/неизвестный вариант ядра в физически правдоподобный solve outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum PackGenerationError { + /// Core-generated data violated a core postcondition. + InternalCoreInvariant { reason: String }, + /// The core introduced an `Unreachable` variant this pack does not know how + /// to encode. The adapter must be upgraded before regenerating artifacts. + IncompatibleCoreContract { reason: String }, +} + +impl core::fmt::Display for PackGenerationError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InternalCoreInvariant { reason } => { + write!(f, "internal core invariant failure: {reason}") + } + Self::IncompatibleCoreContract { reason } => { + write!(f, "incompatible core contract: {reason}") + } + } + } +} + +impl std::error::Error for PackGenerationError {} + +/// Стабильный код физической недостижимости. Тождественен маппингу WASM- +/// границы (`labcolors-wasm/src/engine.rs`). Внутренняя ошибка или неизвестный +/// forward-вариант возвращаются как [`PackGenerationError`] и не сериализуются. +pub fn unreachable_code( + err: &labcolors_core::Unreachable, +) -> Result<&'static str, PackGenerationError> { use labcolors_core::Unreachable as U; match err { - U::BelowContrastFloor { .. } => "below_contrast_floor", - U::ExceedsRange { .. } => "exceeds_range", - U::QuantizationGap { .. } => "quantization_gap", - U::FloorUnreachable { .. } => "floor_unreachable", - U::PolarityMismatch { .. } => "polarity_mismatch", - U::GamutUnsupported => "gamut_unsupported", - U::InvalidInput(_) => "invalid_input", - // `Unreachable` помечен `#[non_exhaustive]`; forward-compat-арм - // тождествен WASM-границе (`labcolors-wasm/src/engine.rs`). - _ => "unreachable", + U::BelowContrastFloor { .. } => Ok("below_contrast_floor"), + U::ExceedsRange { .. } => Ok("exceeds_range"), + U::QuantizationGap { .. } => Ok("quantization_gap"), + U::FloorUnreachable { .. } => Ok("floor_unreachable"), + U::PolarityMismatch { .. } => Ok("polarity_mismatch"), + U::GamutUnsupported => Ok("gamut_unsupported"), + U::InvalidInput(_) => Ok("invalid_input"), + U::InternalInvariant(reason) => Err(PackGenerationError::InternalCoreInvariant { + reason: reason.clone(), + }), + _ => Err(PackGenerationError::IncompatibleCoreContract { + reason: err.to_string(), + }), } } @@ -345,59 +385,64 @@ const SOLVE_CASES: [(&str, ContractSpec, &str); 6] = [ /// Дериватор резолв-векторов. Нейтральная (серая) хрома — резолв /// хью-независим, вектор детерминирован; хью/хрома — естественное расширение /// среза (см. PR). -#[must_use] -pub fn generate_solve() -> Vec { +pub fn generate_solve() -> Result, PackGenerationError> { SOLVE_CASES .iter() - .map(|&(bg, contract, theme)| { - let vc = vc_for_theme(theme); - let bg_input = BgInput::solid(bg).expect("валидный hex фона"); - let outcome = match solve( - bg_input, - contract.to_core(), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &vc, - Gamut::Srgb, - ) { - Ok(s) => SolveOutcome::Solved { - hex: s.hex().to_string(), - lc: s.lc(), - wcag_ratio: s.wcag_ratio(), - floor_override: s.floor_override(), - }, - Err(e) => SolveOutcome::Unreachable { - code: unreachable_code(&e).to_string(), - }, - }; - SolveVector { - bg: bg.to_string(), - contract, - theme: theme.to_string(), - outcome, - } - }) + .map( + |&(bg, contract, theme)| -> Result { + let vc = vc_for_theme(theme); + let bg_input = BgInput::solid(bg).expect("валидный hex фона"); + let outcome = match solve( + bg_input, + contract.to_core(), + Hue::deg(0.0), + ChromaPolicy::Neutral, + &vc, + Gamut::Srgb, + ) { + Ok(s) => SolveOutcome::Solved { + hex: s.hex().to_string(), + lc: s.lc(), + wcag_ratio: s.wcag_ratio(), + floor_override: s.floor_override(), + }, + Err(e) => SolveOutcome::Unreachable { + code: unreachable_code(&e)?.to_string(), + }, + }; + Ok(SolveVector { + bg: bg.to_string(), + contract, + theme: theme.to_string(), + outcome, + }) + }, + ) .collect() } // ───────────────────────────────────────────────────────────────────────────── -// Семейство: мутность +// Семейство: legacy-координата muddiness // ───────────────────────────────────────────────────────────────────────────── -/// Один вектор мутности: hex и его оценка «грязи» `[0,1]`. +/// Один вектор замороженной legacy-координаты `muddiness`. +/// +/// Это `experimental compatibility proxy`: вектор доказывает совпадение +/// численного выхода, но не observer-validated human clean/dirty verdict и не +/// пригодность значения для production decision. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MuddinessVector { /// Цвет, `#RRGGBB`. pub hex: String, - /// Оценка мутности `[0,1]` (0 — чистый, 1 — грязный). + /// Замороженный числовой выход legacy-формулы. pub score: f64, } -/// Цвета для мутности: грязная олива, чистый серый, чистый бренд, грязный хаки. +/// Нейтральный corpus исторических sample inputs; названия не задают семантику. const MUDDINESS_CASES: [&str; 4] = ["#6B6B2E", "#808080", "#007AFF", "#8A7A50"]; -/// Дериватор векторов мутности. +/// Дериватор compatibility-векторов legacy-координаты. #[must_use] pub fn generate_muddiness() -> Vec { MUDDINESS_CASES @@ -426,14 +471,15 @@ pub const FAMILY_FILES: [&str; 5] = [ "muddiness.json", ]; -/// Каноническая кросс-платформенная толерантность сравнения f64 — тождественна -/// `DRIFT_TOL` ядра (`labcolors-core/src/lut.rs`). Наблюдаемый libm-шум +/// Каноническая толерантность сравнения f64 для conformance- +/// пака. Эта константа — SSOT правила сравнения. Наблюдаемый libm-шум /// (`powf`/`atan2`/`ln` расходятся на несколько ULP между платформами) — /// порядка `1e-13`; реальный дрейф (не тот surround, опечатка в матрице, /// путаница единиц) сдвигает значения на целые единицы. `1e-6` заведомо выше -/// шума и заведомо ниже любой настоящей регрессии. Байт-точность f64 -/// кросс-платформенно НЕВОЗМОЖНА — поэтому conformant-ность числовых полей -/// определяется этой толерантностью (hex/enum/строки — точно). +/// наблюдавшегося шума и заведомо ниже настоящей регрессии из corpus. Для +/// libm-dependent путей bit identity между runtime не гарантируется; evidence +/// ограничено аттестованной матрицей. Поэтому conformant-ность числовых полей +/// определяется этой толерантностью (hex/enum/строки — по своему профилю). pub const DRIFT_TOL: f64 = 1e-6; /// Счётчики векторов по семействам — для манифеста и отчёта PR. @@ -448,12 +494,64 @@ pub struct Counts { pub alpha: usize, /// Резолв-векторы. pub solve: usize, - /// Векторы мутности. + /// Compatibility-векторы legacy-координаты `muddiness`. pub muddiness: usize, /// Итого. pub total: usize, } +/// Exact copy of one core-owned migrated branch-sensitive registry row. +/// The release verifier consumes this generated list instead of maintaining a +/// second hand-written semantic registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NumericalSiteManifest { + /// Stable site identity. + pub site_id: String, + /// Branch-sensitive operations. + pub operations: String, + /// Input/output domain. + pub domain: String, + /// Semantic branch affected by the value. + pub branch_effect: String, + /// Lawful outcomes of the stable profile. + pub stable_outcomes: Vec, + /// Sound-bound availability. + pub bound_status: String, + /// Executable boundary corpus identifiers. + pub boundary_corpus: String, + /// Required cross-runtime comparison scope. + pub runtime_matrix: String, + /// Fallback status. + pub fallback_status: String, + /// Explicit compatibility profile, if present. + pub legacy_profile: Option, +} + +/// Generate the release-facing registry directly from the core SSOT. +#[must_use] +pub fn generate_numerical_sites() -> Vec { + numerical_registry_v1() + .iter() + .map(|row| NumericalSiteManifest { + site_id: row.site_id.key().to_string(), + operations: row.operations.to_string(), + domain: row.domain.to_string(), + branch_effect: row.branch_effect.to_string(), + stable_outcomes: row + .stable_outcomes + .iter() + .map(|outcome| outcome.key().to_string()) + .collect(), + bound_status: row.bound_status.key().to_string(), + boundary_corpus: row.boundary_corpus.to_string(), + runtime_matrix: row.runtime_matrix.to_string(), + fallback_status: row.fallback_status.key().to_string(), + legacy_profile: row.legacy_profile.map(str::to_string), + }) + .collect() +} + /// Манифест пака: версии, дайджест и счётчики. `packDigest` — FNV-1a-32 /// (примитив ядра) над каноническими байтами всех семейств; любой дрейф /// значений или состава меняет дайджест. @@ -468,6 +566,8 @@ pub struct Manifest { pub pack_digest: String, /// Счётчики по семействам. pub counts: Counts, + /// Core-owned registry migrated typed-decision numerical sites. + pub numerical_sites: Vec, } /// Весь пак в памяти. `serialize_family` даёт КАНОНИЧЕСКИЕ байты каждого файла @@ -482,21 +582,20 @@ pub struct Pack { pub alpha: Vec, /// Резолв-векторы. pub solve: Vec, - /// Векторы мутности. + /// Compatibility-векторы legacy-координаты `muddiness`. pub muddiness: Vec, } impl Pack { /// Сгенерировать весь пак из канона ядра. - #[must_use] - pub fn generate() -> Self { - Pack { + pub fn generate() -> Result { + Ok(Pack { contrasts: generate_contrasts(), ladders: generate_ladders(), alpha: generate_alpha(), - solve: generate_solve(), + solve: generate_solve()?, muddiness: generate_muddiness(), - } + }) } /// Счётчики семейств. @@ -538,6 +637,7 @@ impl Pack { core_version: core_version().to_string(), pack_digest: self.digest(), counts: self.counts(), + numerical_sites: generate_numerical_sites(), } } @@ -569,19 +669,40 @@ pub fn to_canonical_json(value: &T) -> String { mod tests { use super::*; + #[test] + fn unreachable_code_mapping_is_fallible_without_generic_fallback() { + let error = solve( + BgInput::solid("#FFFFFF").unwrap(), + Contract::text(f64::NAN), + Hue::deg(0.0), + ChromaPolicy::Neutral, + &ViewingConditions::srgb(), + Gamut::Srgb, + ) + .unwrap_err(); + assert_eq!(unreachable_code(&error).unwrap(), "invalid_input"); + + let internal = labcolors_core::Unreachable::InternalInvariant("fixture drift".into()); + assert!(matches!( + unreachable_code(&internal), + Err(PackGenerationError::InternalCoreInvariant { reason }) + if reason == "fixture drift" + )); + } + #[test] fn generation_is_deterministic() { // Дважды сгенерированный пак байт-идентичен — вход всегда даёт тот же // выход (детерминизм канона, перенесённый в пак). - let a = Pack::generate(); - let b = Pack::generate(); + let a = Pack::generate().expect("canonical pack generation"); + let b = Pack::generate().expect("canonical pack generation"); assert_eq!(a, b, "генерация пака недетерминирована"); assert_eq!(a.digest(), b.digest(), "дайджест недетерминирован"); } #[test] fn counts_are_nonempty_and_consistent() { - let pack = Pack::generate(); + let pack = Pack::generate().expect("canonical pack generation"); let c = pack.counts(); assert!(c.total > 0, "пустой пак бессмыслен"); assert_eq!( @@ -593,6 +714,58 @@ mod tests { assert_eq!(c.ladders, LadderPosition::ALL.len()); } + #[test] + fn manifest_numerical_registry_is_generated_from_core_ssot() { + let manifest = Pack::generate() + .expect("canonical pack generation") + .manifest(); + assert_eq!(manifest.numerical_sites, generate_numerical_sites()); + assert_eq!( + manifest.numerical_sites.len(), + numerical_registry_v1().len() + ); + assert!(manifest.numerical_sites.iter().any(|site| { + site.site_id == "glow-target-or-maximum-v1" + && site.stable_outcomes == ["bit-exact", "indeterminate"] + && site.bound_status == "unavailable" + && site.fallback_status == "none" + && site.legacy_profile.as_deref() == Some("legacy-platform-dependent-v1") + })); + } + + #[test] + fn pack_v2_contains_the_exact_source_over_half_tie() { + // ADR-0004 делает этот байтовый шов частью breaking conformance-контракта: + // нормализованный `(byte/255) * alpha * 255` путь ошибочно отдавал + // соседний LSB. Проверка одновременно убивает вакуумные изменения + // версии/счётчика без обязательного доказательного вектора. + let pack = Pack::generate().expect("canonical pack generation"); + let manifest = pack.manifest(); + assert_eq!(PACK_VERSION, "2.0.0", "half-tie требует pack v2"); + assert_eq!(manifest.pack_version, PACK_VERSION); + assert_eq!( + manifest.core_version, "0.2.0", + "pack v2 обязан быть сгенерирован breaking-версией ядра" + ); + assert_eq!( + pack.alpha.len(), + 7, + "alpha-family v2 обязана иметь 7 векторов" + ); + assert_eq!(manifest.counts.alpha, pack.alpha.len()); + assert_eq!(manifest.counts.total, 82, "состав pack v2 изменился"); + + let half_tie = pack + .alpha + .iter() + .find(|v| v.tint == "#C0B2FA" && v.alpha == 0.122 && v.bg == "#000000") + .expect("в pack v2 нет обязательного half-tie из ADR-0004"); + assert_eq!( + half_tie.composite, "#17161F", + "byte-reference round-half-up должен выбрать верхний LSB" + ); + } + #[test] fn canonical_json_serialization_is_deterministic() { // Канонический JSON — чистая функция структуры (serde_json + ryu): @@ -600,7 +773,7 @@ mod tests { // байт-точного гейта дрейфа и дайджеста (сравнение по СЕРИАЛИЗАЦИИ, не // по parse — парсер serde_json по умолчанию не round-trip-точен для // f64, поэтому опираемся на детерминизм сериализации, а не парсинга). - let pack = Pack::generate(); + let pack = Pack::generate().expect("canonical pack generation"); assert_eq!( to_canonical_json(&pack.contrasts), to_canonical_json(&pack.contrasts) @@ -618,7 +791,7 @@ mod tests { #[test] fn solve_pack_contains_reachable_and_unreachable() { // Пак честен: есть и успешный резолв, и намеренный отказ с кодом. - let solve = generate_solve(); + let solve = generate_solve().expect("canonical solve vectors"); assert!( solve .iter() diff --git a/crates/labcolors-conformance/tests/reference_runner.rs b/crates/labcolors-conformance/tests/reference_runner.rs index 85238385..1b2ca9f3 100644 --- a/crates/labcolors-conformance/tests/reference_runner.rs +++ b/crates/labcolors-conformance/tests/reference_runner.rs @@ -3,11 +3,11 @@ //! Четыре независимых слоя, каждый ловит свой класс дрейфа: //! //! 1. **Гейт дрейфа (толерантный)** — ядро воспроизводит КАЖДЫЙ закоммиченный -//! вектор: числовые поля в пределах [`DRIFT_TOL`] (`1e-6`, канон ядра — -//! `labcolors-core/src/lut.rs`), hex/строки/enum/bool — точно. Байт-точность -//! f64 кросс-платформенно НЕВОЗМОЖНА: `powf`/`atan2`/`ln` расходятся на -//! несколько ULP между libm разных ОС (векторы генерятся на одной, CI бежит -//! на другой). Реальный дрейф (не тот surround, опечатка в матрице) сдвигает +//! вектор: числовые поля в пределах [`DRIFT_TOL`] (`1e-6`, SSOT пака — +//! `labcolors-conformance/src/lib.rs`), hex/строки/enum/bool — точно. Для +//! libm-dependent путей bit identity f64 между runtime не гарантируется: +//! `powf`/`atan2`/`ln` могут расходиться на несколько ULP между libm разных +//! ОС. Реальный дрейф (не тот surround, опечатка в матрице) сдвигает //! значения на целые единицы — на десять порядков выше толерантности. //! Та же толерантность гасит и неточность парсера f64 у `serde_json`. //! @@ -142,7 +142,7 @@ fn core_reproduces_committed_alpha() { #[test] fn core_reproduces_committed_solve() { let committed: Vec = parse("solve.json"); - let fresh = generate_solve(); + let fresh = generate_solve().expect("canonical solve vectors"); assert_eq!(committed.len(), fresh.len(), "solve.json: изменился состав"); for (c, f) in committed.iter().zip(&fresh) { assert_eq!( @@ -188,7 +188,7 @@ fn core_reproduces_committed_muddiness() { "muddiness.json: изменился состав" ); for (c, f) in committed.iter().zip(&fresh) { - assert_eq!(c.hex, f.hex, "ключ мутности"); + assert_eq!(c.hex, f.hex, "legacy proxy key"); approx(c.score, f.score, &format!("muddiness {}", c.hex)); } } @@ -198,10 +198,16 @@ fn core_reproduces_committed_muddiness() { #[test] fn manifest_metadata_matches_core() { let committed: Manifest = parse(MANIFEST_FILE); - let fresh = Pack::generate().manifest(); + let fresh = Pack::generate() + .expect("canonical pack generation") + .manifest(); assert_eq!(committed.pack_version, fresh.pack_version, "версия пака"); assert_eq!(committed.core_version, fresh.core_version, "версия ядра"); assert_eq!(committed.counts, fresh.counts, "счётчики семейств"); + assert_eq!( + committed.numerical_sites, fresh.numerical_sites, + "numerical registry обязан быть exact copy core SSOT" + ); } // ── Слой 3: согласованность дайджеста над сырыми байтами ────────────────────── diff --git a/crates/labcolors-core/Cargo.toml b/crates/labcolors-core/Cargo.toml index 0c80c6f8..b70a1005 100644 --- a/crates/labcolors-core/Cargo.toml +++ b/crates/labcolors-core/Cargo.toml @@ -1,10 +1,13 @@ [package] name = "labcolors-core" +description = "Dependency-free core compiler and runtime resolver for client-owned color-token contracts." +readme = "README.md" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true [dependencies] @@ -39,3 +42,7 @@ harness = false [[bench]] name = "forward" harness = false + +[[bench]] +name = "glow_alpha" +harness = false diff --git a/crates/labcolors-core/LICENSE b/crates/labcolors-core/LICENSE new file mode 120000 index 00000000..30cff740 --- /dev/null +++ b/crates/labcolors-core/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/labcolors-core/README.md b/crates/labcolors-core/README.md new file mode 100644 index 00000000..1715ae4a --- /dev/null +++ b/crates/labcolors-core/README.md @@ -0,0 +1,33 @@ +# labcolors-core + +Dependency-free compiler and runtime resolver for client-owned colour-token +contracts. + +The client owns token identifiers, aliases, hierarchy, component states and +design semantics. The core treats those identifiers as opaque and owns the +mathematics: graph resolution, contrast, compositing, adaptation, finite output +and numerical certificates. Physical colours are contextual results, not the +source schema. + +## Exact encoded-sRGB8 example + +The declared source-over reference rounds the exact half-tie upward: + +```rust +use labcolors_core::alpha::composite_over_srgb8; + +# fn main() -> Result<(), String> { +let composite = composite_over_srgb8( + [0xC0, 0xB2, 0xFA], + 0.122, + [0x00, 0x00, 0x00], +)?; +assert_eq!(composite, [0x17, 0x16, 0x1F]); +# Ok(()) +# } +``` + +Exact composite guarantees do not extend to an unknown renderer, display, +spatial blur field or the explicit platform-dependent legacy Glow decision. +See the repository migration guide and conformance pack for versioned boundary +details. diff --git a/crates/labcolors-core/benches/glow_alpha.rs b/crates/labcolors-core/benches/glow_alpha.rs new file mode 100644 index 00000000..97dc7e99 --- /dev/null +++ b/crates/labcolors-core/benches/glow_alpha.rs @@ -0,0 +1,61 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use labcolors_core::ViewingConditions; +use labcolors_core::glow::{GlowDecisionProfileV1, solve_screen_alpha_for_dj}; +use std::hint::black_box; + +fn bench_glow_alpha(c: &mut Criterion) { + let vc = ViewingConditions::dim_surround(); + let mut group = c.benchmark_group("glow_alpha"); + for (name, tint, background, target) in [ + ("base", "#4A8FFF", "#101012", 2.3006), + ("bloom", "#FF3B30", "#101012", 13.3251), + // Белый фон даёт точный no-op из одного состояния, + // а не наихудший случай перебора. + ("exact-noop", "#3E87FF", "#FFFFFF", 2.3006), + // Длинный поток в доказанной границе ≤ 766 состояний. Соседний модульный + // тест закрепляет недостижимость цели 101, поэтому legacy target/max + // обязан прочитать поток до конца; tight максимума отдельно не заявлен. + ("long-unreachable", "#FFFFFE", "#000101", 101.0), + ] { + group.bench_with_input( + BenchmarkId::new("legacy", name), + &(tint, background, target), + |b, &(tint, background, target)| { + b.iter(|| { + black_box( + solve_screen_alpha_for_dj( + black_box(tint), + black_box(background), + black_box(target), + GlowDecisionProfileV1::LegacyPlatformDependentV1, + black_box(&vc), + ) + .expect("benchmark inputs are valid"), + ) + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("stable", name), + &(tint, background, target), + |b, &(tint, background, target)| { + b.iter(|| { + black_box( + solve_screen_alpha_for_dj( + black_box(tint), + black_box(background), + black_box(target), + GlowDecisionProfileV1::StableV1, + black_box(&vc), + ) + .expect("benchmark inputs are valid"), + ) + }); + }, + ); + } + group.finish(); +} + +criterion_group!(benches, bench_glow_alpha); +criterion_main!(benches); diff --git a/crates/labcolors-core/examples/figma_anchor_provenance.rs b/crates/labcolors-core/examples/figma_anchor_provenance.rs index 8e4bc992..afdd6969 100644 --- a/crates/labcolors-core/examples/figma_anchor_provenance.rs +++ b/crates/labcolors-core/examples/figma_anchor_provenance.rs @@ -74,6 +74,7 @@ fn srgb_gamma_encode(v: f64) -> f64 { /// потребляет её, а не держит вторую копию формулы. fn composite_gamma(fg: [f64; 3], alpha: f64, bg: [f64; 3]) -> [f64; 3] { labcolors_core::alpha::composite_over_encoded(fg, alpha, bg) + .expect("данные provenance-примера лежат в encoded-sRGB домене") } /// Композит в линейном свете: каналы декодируются из gamma, смешиваются, diff --git a/crates/labcolors-core/examples/unique_yellow_provenance.rs b/crates/labcolors-core/examples/unique_yellow_provenance.rs index 1008975c..cb7641e7 100644 --- a/crates/labcolors-core/examples/unique_yellow_provenance.rs +++ b/crates/labcolors-core/examples/unique_yellow_provenance.rs @@ -1,26 +1,30 @@ -//! Воспроизводимая деривация M-12 `H_Y_DEG` — Oklab hue уникального жёлтого. +//! Воспроизводимая compatibility-арифметика M-12 `H_Y_DEG` для выбранного 578nm reference. //! //! Запуск: `cargo run -p labcolors-core --example unique_yellow_provenance` //! -//! Метод (Zone B slice 5, 2026-07-03): -//! 1. λ = 578nm — инвариантная точка жёлтого Бецольда-Брюкке -//! (Purdy 1937, Am. J. Psychol. 49, 313–315; Jacobs & Wascher 1967, -//! J. Opt. Soc. Am. 57, 1155–1156). +//! Метод (Zone B slice 5, статус уточнён 2026-07-11): +//! 1. λ = 578nm — замороженный spectral reference, а не универсальный +//! unique-yellow наблюдателя. Observer claim отклонён в M-12. //! 2. CMF при 578nm — линейная интерполяция официальной CIE 1931 2° 5нм-таблицы. //! 3. Нормировка к Y=1 (hue инвариантен к радиансности: масштабирование LMS′ //! множит a и b одинаково — отношение в atan2 не меняется; проверяется ниже). -//! 4. XYZ → Oklab НАПРЯМУЮ через M1/M2 (Ottosson 2020) — БЕЗ проекции в sRGB: +//! 4. XYZ → Oklab НАПРЯМУЮ через замороженные original-2020 M1/M2 — БЕЗ +//! проекции в sRGB: //! покомпонентный гамут-клэмп не сохраняет оттенок (демонстрируется ниже). //! 5. h = atan2(b, a). //! -//! Печатает также golden-значения muddiness для characterization-тестов. +//! Это provenance именно frozen compatibility value `90.4011°`. Production sRGB +//! path использует revised-2021 matrices (`spaces/oklab.rs`), поэтому результат +//! version-sensitive. Скрипт не доказывает observer meaning или Hanning-эвристику. // CIE 1931 2° standard observer, 5nm table (x̄, ȳ, z̄) const CMF_575: [f64; 3] = [0.8425, 0.9154, 0.0018]; const CMF_580: [f64; 3] = [0.9163, 0.8700, 0.00165]; -/// XYZ (D65) → LMS, Ottosson 2020 M1. Не входит в продуктивное ядро -/// (ядро работает от sRGB), поэтому определена здесь, в скрипте провенанса. +/// XYZ (D65 reference) → LMS, original Ottosson 2020 M1. +/// +/// Эта version-frozen матрица не является SSOT production sRGB path; она +/// сохранена только чтобы воспроизводить опубликованное compatibility value M-12. #[rustfmt::skip] const M1_XYZ_TO_LMS: [[f64; 3]; 3] = [ [0.8189330101, 0.3618667424, -0.1288597137], @@ -28,7 +32,7 @@ const M1_XYZ_TO_LMS: [[f64; 3]; 3] = [ [0.0482003018, 0.2643662691, 0.6338517070], ]; -/// LMS′ → Oklab, Ottosson 2020 M2 (та же матрица, что в spaces/oklab.rs). +/// LMS′ → Oklab, original Ottosson 2020 M2. #[rustfmt::skip] const M2_LMS_TO_OKLAB: [[f64; 3]; 3] = [ [0.2104542553, 0.7936177850, -0.0040720468], @@ -73,7 +77,7 @@ fn main() { let h = hue_deg(lab); println!("Oklab a={:+.10} b={:+.10}", lab[1], lab[2]); println!( - "H_Y = {h:.4}° (константа M-12: {})", + "selected-578nm hue = {h:.4}° (frozen M-12: {})", labcolors_core::cleanliness::H_Y_DEG ); @@ -93,22 +97,4 @@ fn main() { "slice-4 XYZ=(1.207,1,0) без клэмпа: {:.4}° (после sRGB-клэмпа было 96.9°)", hue_deg(lab_wrong) ); - - // Golden-значения muddiness для characterization-тестов (Fowler class B) - println!("\n— mud goldens (muddiness_from_hex) —"); - for (label, hex) in [ - ("olive", "#6B6B2E"), - ("babypoop", "#937C00"), - ("puke", "#9AAE07"), - ("gold1", "#9e6c00"), - ("gold2", "#8f6424"), - ("grey", "#808080"), - ("teal", "#008080"), - ("navy", "#000080"), - ("red", "#FF0000"), - ("blue", "#0000FF"), - ] { - let mud = labcolors_core::cleanliness::muddiness_from_hex(hex).unwrap(); - println!("{label:9} {hex} mud={mud:.8}"); - } } diff --git a/crates/labcolors-core/src/accent_surface.rs b/crates/labcolors-core/src/accent_surface.rs index 88e4c063..b4599c66 100644 --- a/crates/labcolors-core/src/accent_surface.rs +++ b/crates/labcolors-core/src/accent_surface.rs @@ -122,8 +122,7 @@ pub fn render_surface( SurfaceMaterial::Solid => Ok(AccentSurface::Solid { hex: solid_hex }), SurfaceMaterial::Alpha => { let (tint_hex, alpha) = - resolve_alpha_analog_hex(&solid_hex, requested_alpha, base_hex)? - .expect("солид/база из валидных hex ⇒ альфа-аналог разрешим"); + resolve_alpha_analog_hex(&solid_hex, requested_alpha, base_hex)?; Ok(AccentSurface::Alpha { tint_hex, alpha }) } } diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index 454bf875..c4d21d39 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -11,6 +11,7 @@ //! changes: the proof that any company plugs in its own design system. use crate::config::fixture::labui_reference; +use crate::config::test_support::resolved_repr as repr; use crate::ladder::{LadderPosition, ThemeAnchors}; use crate::solve::Floor; use crate::{ @@ -36,18 +37,6 @@ fn grid() -> ([(ViewingConditions, &'static str); 2], [&'static str; 6]) { ) } -/// Canonical stable representation of a resolved role. -fn repr(res: &Resolved) -> String { - match res { - Resolved::Color { solved, .. } => solved.hex().to_string(), - Resolved::Translucent(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), - Resolved::Glow(g) => format!("glow({},{},{:.4})", g.core_hex(), g.halo_hex(), g.alpha()), - Resolved::Material(m) => format!("material({},{:.4})", m.tint_hex(), m.alpha()), - Resolved::None => "none".to_string(), - Resolved::Unreachable(_) => "UNREACHABLE".to_string(), - } -} - // ───────────────────────────────────────────────────────────────────────────── // 1. Frozen golden byte-identity gate // ───────────────────────────────────────────────────────────────────────────── @@ -105,10 +94,18 @@ fn labui_fixture_emission_is_byte_identical_to_frozen_golden() { golden.lines().count(), "labui emission line count drifted — a role/alias/grid point changed" ); - assert_eq!( - got, golden, + let changed: Vec = got + .lines() + .zip(golden.lines()) + .enumerate() + .filter(|(_, (actual, expected))| actual != expected) + .map(|(index, (actual, expected))| format!("line {}\n- {expected}\n+ {actual}", index + 1)) + .collect(); + assert!( + changed.is_empty(), "labui fixture emission drifted from the frozen golden \ - (regenerate with BLESS_LABUI_GOLDEN=1 only for a reviewed change)" + (regenerate only for a reviewed change):\n{}", + changed.join("\n") ); } diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index b7d864d9..c137ab88 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -1,14 +1,14 @@ //! Альфа-аналог солидного цвета: прямой и обратный ход straight-alpha //! композита в ГАММА-КОДИРОВАННОМ sRGB. //! -//! Пространство закона — измеренный факт, не выбор: Figma композитит на -//! канвасе попарно по кодированным каналам `c = α·t + (1−α)·b`, и ровно этот -//! путь воспроизводит все 12 семантических dJ'-якорей движка +//! Кодированный домен заземлён измерением: модель `c = α·t + (1−α)·b` +//! воспроизводит все 12 семантических Figma-якорей движка //! (`reference/labui-figma-structure.md` §3–§4, воспроизводимо -//! `cargo run -p labcolors-core --example figma_anchor_provenance`); браузерный -//! композитинг CSS-альфы живёт в том же device-пространстве. Линейный свет -//! (внутренний `srgb_from_hex`) здесь не участвует — он для колориметрии, не -//! для наложения. +//! `cargo run -p labcolors-core --example figma_anchor_provenance`). Для +//! эмиссии контракт дополнительно фиксирует **encoded-sRGB8 source-over +//! reference**: арифметика идёт на байтах `0..255` с одним итоговым round. +//! Совпадение конкретного renderer зависит от его color-management профиля и +//! проверяется отдельно; линейный свет здесь только для последующей колориметрии. //! //! # Зачем обратный ход //! @@ -19,10 +19,12 @@ //! t = (c − (1−α)·b) / α (по каналам, кодированные значения) //! ``` //! -//! Композит инверсии равен солиду ПО ПОСТРОЕНИЮ, поэтому контраст, dJ' и -//! WCAG-статус альфа-аналога на каноническом фоне тождественны солиду — -//! AA-корректность наследуется алгеброй, а не проверяется заново (теорема -//! запинена тестом `inversion_identity_is_exact_on_continuous_values`). +//! В вещественной алгебре continuous encoded-sRGB композит инверсии равен +//! солиду тождественно. Реализация на `binary64` честно отделена: ошибка +//! round-trip ограничена выведенной оценкой `8·ε/α` и проверяется тестом +//! `inversion_identity_respects_derived_binary64_error_bound`. После эмиссии тинт уже +//! лежит на sRGB8-сетке; побайтовый контракт ниже доказывается и проверяется +//! отдельно, а не приписывается непрерывной функции. //! На ином фоне композит другой — это и есть смысл альфы (адаптация к //! подложке), гарантия формулируется для фона, на котором решён солид. //! @@ -35,17 +37,27 @@ //! t ≤ 1 ⇔ α ≥ (c − b) / (1 − b) (канал с c > b; при b = 1 симметрично) //! ``` //! -//! [`min_alpha_encoded`] — максимум этих границ по каналам; ниже него -//! [`invert_composite_encoded`] честно возвращает `None`, а не клампит -//! (кламп молча сдвинул бы композит — подмена запрещена). +//! [`min_alpha_encoded`] начинает с максимума этих алгебраических границ, затем +//! возвращает первый `binary64`, на котором строгая binary64-инверсия реально +//! лежит в `[0,1]³`. [`invert_composite_encoded`] может также принять более +//! раннюю граничную пару только если повторный binary64-композит побитно равен +//! входному solid; глобального epsilon и безусловного clamp нет. Это не минимальная α +//! дискретного sRGB8-композита: `#010000` над чёрным уже округляется из белого +//! красного канала при `α=0.5/255`, тогда как strict-binary64 пол равен `1/255`. //! -//! Квантование: солид, прочитанный из 8-битного hex, несёт ошибку ≤ 0.5/255 на -//! канал; инверсия масштабирует её в 1/α раз, поэтому восстановленный тинт -//! отклоняется от истинного не более чем на `0.5/(255·α)` на канал. Граница -//! запинена тестом `quantisation_error_bound_is_honoured`; при малых α точное -//! побайтное восстановление тинта не гарантируется — гарантируется прямой ход -//! (композит канонического тинта воспроизводит опорный hex побайтно, 12/12 -//! Figma-пар в `figma_neutral_ladder_pairs_roundtrip`). +//! Эмиссионный путь НЕ использует continuous-пол как суррогат byte-grid пола. +//! После квантования solid/background он сначала исчерпывающе решает три +//! независимых одноканальных диапазона тинта на запрошенной alpha. Если решения +//! нет, lower-bound по упорядоченным битам `f64` находит первый `binary64`, +//! проходящий ТОТ ЖЕ [`composite_over_srgb8`]; predecessor обязан не проходить. +//! Поэтому округлительно разрешимые пары вроде `#FF0000 @ 0.12 → #1F0000` +//! сохраняют запрошенную прозрачность, а `alphaCoerced` не врёт о деградации. +//! Весь одноканальный домен из 65 536 `(S, B)` проверяет и точный композит, и +//! минимальность фактической alpha. +//! +//! Это не обещает восстановить исходный тинт: солид из 8-битного hex несёт +//! ошибку ≤ 0.5/255, которую инверсия масштабирует в `1/α` раз. Граница +//! `0.5/(255·α)` запинена тестом `quantisation_error_bound_is_honoured`. use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; @@ -56,17 +68,26 @@ fn is_encoded_rgb(v: [f64; 3]) -> bool { .all(|x| x.is_finite() && (0.0..=1.0).contains(&x)) } -/// Прямой ход: straight-alpha композит `α·tint + (1−α)·bg` по кодированным -/// каналам — закон Figma/браузера (см. модульную документацию). -/// -/// Домен: `tint`/`bg` — кодированные цвета в `[0,1]³`, `alpha ∈ [0,1]`; -/// вне домена — `debug_assert` (горячий путь чистой алгебры не платит за -/// проверки в релизе; строгие Option-контракты — у инверсии и `min_alpha`). -pub fn composite_over_encoded(tint: [f64; 3], alpha: f64, bg: [f64; 3]) -> [f64; 3] { - debug_assert!( - is_encoded_rgb(tint) && is_encoded_rgb(bg) && (0.0..=1.0).contains(&alpha), - "composite_over_encoded: вход вне домена кодированного sRGB" - ); +/// Ровно тот же монотонный порядок binary64-операций, что у официального JS- +/// потребителя на непрозрачной подложке: `bg + alpha * (tint - bg)`. +/// Expanded-форма `tint*alpha + bg*(1-alpha)` немонотонна на отдельных ULP- +/// швах из-за двух округлений, поэтому не годится для доказуемого lower-bound. +fn source_over_channel_value(tint: u8, alpha: f64, bg: u8) -> f64 { + f64::from(bg) + alpha * (f64::from(tint) - f64::from(bg)) +} + +fn source_over_channel_srgb8(tint: u8, alpha: f64, bg: u8) -> u8 { + source_over_channel_value(tint, alpha, bg).round() as u8 +} + +/// Непрерывная алгебра straight-alpha до квантования. Она нужна инверсии и +/// материалам; путь эмитируемого 8-битного пикселя использует +/// [`composite_over_srgb8`], чтобы нормализация `byte/255` не сдвигала half-tie. +pub(crate) fn composite_over_encoded_unchecked( + tint: [f64; 3], + alpha: f64, + bg: [f64; 3], +) -> [f64; 3] { [ alpha * tint[0] + (1.0 - alpha) * bg[0], alpha * tint[1] + (1.0 - alpha) * bg[1], @@ -74,73 +95,249 @@ pub fn composite_over_encoded(tint: [f64; 3], alpha: f64, bg: [f64; 3]) -> [f64; ] } +/// Прямой непрерывный ход: `α·tint + (1−α)·bg` по кодированным каналам. +/// +/// Это алгебра над `[0,1]`, не финальное округление sRGB8-reference. Для +/// эмитированных sRGB8-цветов используй [`composite_over_srgb8`]. +/// +/// # Errors +/// +/// `Err`, если канал не конечен/вне `[0,1]` либо `alpha` не конечна/вне +/// `[0,1]`. Публичная граница не зажимает мусор и одинакова в debug/release. +pub fn composite_over_encoded( + tint: [f64; 3], + alpha: f64, + bg: [f64; 3], +) -> Result<[f64; 3], String> { + if !is_encoded_rgb(tint) { + return Err(format!("tint вне конечного encoded-sRGB [0,1]: {tint:?}")); + } + if !is_encoded_rgb(bg) { + return Err(format!("bg вне конечного encoded-sRGB [0,1]: {bg:?}")); + } + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok(composite_over_encoded_unchecked(tint, alpha, bg)) +} + +/// Straight-alpha непрозрачного sRGB8-фона в шкале `0..255`, которую несут +/// каналы эмитированного CSS-цвета. +/// +/// Округление выполняется один раз, после композиции. Это существенно на +/// half-tie: `(250/255)·0.122·255` может стать `30.499…`, хотя эталонная +/// byte-reference `250·0.122` равен `30.5` и по round-half-up даёт байт 31. +/// Binary64-операции выполняются как монотонная affine-форма +/// `bg + alpha*(tint-bg)` — ровно тот же порядок использует официальный JS- +/// потребитель на непрозрачной подложке. Expanded-форма запрещена: на ULP-швах +/// она способна дать последовательность PASS→FAIL→PASS при росте alpha. +/// +/// # Errors +/// +/// `Err`, если `alpha` не конечна или лежит вне `[0,1]`. +pub fn composite_over_srgb8(tint: [u8; 3], alpha: f64, bg: [u8; 3]) -> Result<[u8; 3], String> { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok([ + source_over_channel_srgb8(tint[0], alpha, bg[0]), + source_over_channel_srgb8(tint[1], alpha, bg[1]), + source_over_channel_srgb8(tint[2], alpha, bg[2]), + ]) +} + +fn encoded_to_srgb8(rgb: [f64; 3], label: &str) -> Result<[u8; 3], String> { + if !is_encoded_rgb(rgb) { + return Err(format!("{label} вне конечного encoded-sRGB [0,1]: {rgb:?}")); + } + Ok(rgb.map(|channel| (channel * 255.0).round() as u8)) +} + +fn hex_from_srgb8(rgb: [u8; 3]) -> String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} + +/// Общий внутренний путь для semantic-метрик: вход уже квантован для эмиссии, +/// но повторная проверка не позволяет новому call site протащить NaN или clamp. +pub(crate) fn composite_srgb8_from_encoded( + tint: [f64; 3], + alpha: f64, + bg: [f64; 3], +) -> Result<[u8; 3], String> { + composite_over_srgb8( + encoded_to_srgb8(tint, "tint")?, + alpha, + encoded_to_srgb8(bg, "bg")?, + ) +} + +pub(crate) fn composite_hex_from_encoded( + tint: [f64; 3], + alpha: f64, + bg: [f64; 3], +) -> Result { + composite_srgb8_from_encoded(tint, alpha, bg).map(hex_from_srgb8) +} + /// Обратный ход: тинт, чей композит с `alpha` на `bg` равен `solid`. /// /// `None`, если вход вне домена (`solid`/`bg` не кодированные цвета `[0,1]³` /// или не конечные), `alpha` не в `(0, 1]` (при α=0 инверсия вырождена — /// композит не зависит от тинта), либо хотя бы один канал тинта выходит из -/// гамута `[0,1]` (α ниже [`min_alpha_encoded`]). +/// гамута `[0,1]`. Значение за границей канонизируется в 0/1 только при +/// побитном постусловии прямого binary64-хода; иначе возвращается `None`. pub fn invert_composite_encoded(solid: [f64; 3], alpha: f64, bg: [f64; 3]) -> Option<[f64; 3]> { if !(is_encoded_rgb(solid) && is_encoded_rgb(bg) && alpha > 0.0 && alpha <= 1.0) { return None; } let mut tint = [0.0; 3]; + let mut projected_boundary = [false; 3]; for c in 0..3 { - let t = (solid[c] - (1.0 - alpha) * bg[c]) / alpha; - // EPS: допуск на плавающую арифметику самой инверсии (не на квантование - // входа) — значения, вышедшие за [0,1] на машинный шум, клампятся к - // границе; настоящий выход из гамута отвергается. - const EPS: f64 = 1e-12; - if !(-EPS..=1.0 + EPS).contains(&t) { + let t = bg[c] + (solid[c] - bg[c]) / alpha; + if (0.0..=1.0).contains(&t) { + tint[c] = t; + } else { + // Никакого epsilon: пробуем только математическую границу gamut. + // Она допустима лишь когда ТОТ ЖЕ binary64-прямой ход побитно + // воспроизводит входной solid; иначе это была бы подмена цвета. + tint[c] = if t < 0.0 { 0.0 } else { 1.0 }; + projected_boundary[c] = true; + } + } + if projected_boundary.into_iter().any(|projected| projected) { + let recomposed = composite_over_encoded_unchecked(tint, alpha, bg); + if (0..3).any(|c| projected_boundary[c] && recomposed[c].to_bits() != solid[c].to_bits()) { return None; } - tint[c] = t.clamp(0.0, 1.0); } Some(tint) } -/// Минимальная α, при которой инверсия `solid` над `bg` разрешима в гамуте -/// (все каналы тинта в `[0,1]`). Для `solid == bg` равна 0 (любой видимый -/// эффект отсутствует, тинт = фон при любой α). +/// Строгая binary64-инверсия уже проверенного домена. /// -/// `None` при входе вне домена (не кодированный цвет `[0,1]³` / не конечный) — -/// молчаливый ответ на мусор был бы ложным обещанием разрешимости. -pub fn min_alpha_encoded(solid: [f64; 3], bg: [f64; 3]) -> Option { - if !is_encoded_rgb(solid) || !is_encoded_rgb(bg) { - return None; +/// Форма `bg + (solid-bg)/alpha` уменьшает отмену близких величин. Для каждого +/// знака `solid-bg` правильно округлённые IEEE-операции монотонны по alpha; +/// поэтому предикат попадания в `[0,1]` пригоден для exact lower-bound ниже. +fn invert_composite_strict(solid: [f64; 3], alpha: f64, bg: [f64; 3]) -> Option<[f64; 3]> { + let mut tint = [0.0; 3]; + for c in 0..3 { + let t = bg[c] + (solid[c] - bg[c]) / alpha; + if !(0.0..=1.0).contains(&t) { + return None; + } + tint[c] = t; } + Some(tint) +} + +/// Binary64-вычисление алгебраической границы непрерывной инверсии. +/// +/// Это только стартовая точка поиска: округление деления может поставить её +/// как выше, так и ниже первого `binary64`, проходящего фактический строгий +/// предикат [`invert_composite_strict`]. +fn analytic_alpha_candidate(solid: [f64; 3], bg: [f64; 3]) -> f64 { let mut lo = 0.0f64; for c in 0..3 { let (s, b) = (solid[c], bg[c]); let bound = if s < b { - (b - s) / b // b > s ≥ 0 ⇒ b > 0, деление определено + (b - s) / b } else if s > b { - (s - b) / (1.0 - b) // s > b ⇒ b < 1, деление определено + (s - b) / (1.0 - b) } else { 0.0 }; lo = lo.max(bound); } - Some(lo.clamp(0.0, 1.0)) + lo.clamp(0.0, 1.0) +} + +/// Первый `binary64`, на котором строгая binary64-инверсия `solid` над `bg` +/// возвращает тинт в `[0,1]³`. Для `solid == bg` по определению равен 0. +/// Предыдущее представимое число обязательно не проходит тот же предикат. +/// Это точный дискретный минимум реализации, но не точный вещественный инфимум: +/// округление операций способно сдвинуть его относительно алгебраической +/// границы в обе стороны. +/// +/// Это также не минимальная α на sRGB8-сетке: итоговый round иногда делает +/// целевой байт достижимым раньше. +/// Например, `solid=1/255`, `bg=0` даёт здесь `1/255`, хотя tint byte 255 при +/// `α=0.5/255` уже композитится в byte 1. +/// +/// `None` при входе вне домена (не кодированный цвет `[0,1]³` / не конечный) — +/// молчаливый ответ на мусор был бы ложным обещанием разрешимости. +pub fn min_alpha_encoded(solid: [f64; 3], bg: [f64; 3]) -> Option { + if !is_encoded_rgb(solid) || !is_encoded_rgb(bg) { + return None; + } + if solid == bg { + return Some(0.0); + } + let analytic = analytic_alpha_candidate(solid, bg); + // Для двух разных цветов хотя бы один канал различается. В валидном + // `[0,1]`-домене соответствующий знаменатель строго положителен, поэтому и + // его поканальная граница, и максимум границ обязаны быть больше нуля. + debug_assert!( + analytic > 0.0, + "solid != bg обязан давать положительную analytic alpha: solid={solid:?}, bg={bg:?}, analytic={analytic}" + ); + + // Положительные finite f64 упорядочены unsigned-битами. Сначала + // экспоненциально находим ближайшую пару «не проходит / проходит» вокруг + // аналитического кандидата; так обычный случай занимает несколько проб, + // но ответ не зависит от эвристического лимита числа ULP. + let analytic_bits = analytic.to_bits(); + let one_bits = 1.0f64.to_bits(); + let (mut failing_bits, mut passing_bits) = + if invert_composite_strict(solid, analytic, bg).is_some() { + let mut passing = analytic_bits; + let mut step = 1_u64; + loop { + let candidate = passing.saturating_sub(step); + if invert_composite_strict(solid, f64::from_bits(candidate), bg).is_none() { + break (candidate, passing); + } + passing = candidate; + step = step.saturating_mul(2); + } + } else { + let mut failing = analytic_bits; + let mut step = 1_u64; + loop { + let candidate = failing.saturating_add(step).min(one_bits); + if invert_composite_strict(solid, f64::from_bits(candidate), bg).is_some() { + break (failing, candidate); + } + failing = candidate; + step = step.saturating_mul(2); + } + }; + + // Монотонность строгой формы превращает найденный интервал в обычный + // exact lower-bound по целочисленным битам — epsilon и float-midpoint не + // участвуют. + while passing_bits - failing_bits > 1 { + let mid_bits = failing_bits + (passing_bits - failing_bits) / 2; + let mid = f64::from_bits(mid_bits); + if invert_composite_strict(solid, mid, bg).is_some() { + passing_bits = mid_bits; + } else { + failing_bits = mid_bits; + } + } + Some(f64::from_bits(passing_bits)) } /// Hex-обёртка прямого хода: композит `tint_hex @ alpha` над `bg_hex`, -/// квантованный до 8-битного hex — цвет, который реально покажет дисплей. +/// квантованный до 8-битного hex — канонический source-over reference-композит. /// /// # Errors /// -/// `Err` при невалидном hex на любом входе или `alpha` вне `[0,1]`/NaN — -/// публичная поверхность не пропускает мусор в release-алгебру (внутри только -/// `debug_assert`). +/// `Err` при невалидном hex на любом входе либо неконечной/внедиапазонной +/// `alpha`; ни одна сборка не подменяет такой ввод clamp-результатом. pub fn composite_hex(tint_hex: &str, alpha: f64, bg_hex: &str) -> Result { - if !(alpha.is_finite() && (0.0..=1.0).contains(&alpha)) { - return Err(format!("alpha must be in [0,1], got {alpha}")); - } let tint = srgb_encoded_from_hex(tint_hex)?; let bg = srgb_encoded_from_hex(bg_hex)?; - Ok(hex_from_srgb_encoded(composite_over_encoded( - tint, alpha, bg, - ))) + composite_hex_from_encoded(tint, alpha, bg) } /// Hex-обёртка обратного хода: тинт для `solid_hex @ alpha` над `bg_hex`, @@ -148,18 +345,25 @@ pub fn composite_hex(tint_hex: &str, alpha: f64, bg_hex: &str) -> Result Result, String> { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } let solid = srgb_encoded_from_hex(solid_hex)?; let bg = srgb_encoded_from_hex(bg_hex)?; Ok(invert_composite_encoded(solid, alpha, bg).map(hex_from_srgb_encoded)) } -/// Hex-обёртка [`min_alpha_encoded`]. +/// Hex-обёртка strict-binary64 пола [`min_alpha_encoded`]. Значение не следует +/// интерпретировать как вещественный инфимум или минимальную α дискретного +/// sRGB8-композита. /// /// # Errors /// @@ -172,16 +376,19 @@ pub fn min_alpha_hex(solid_hex: &str, bg_hex: &str) -> Result { .expect("hex-вход всегда в домене byte/255 — None недостижим по построению")) } -/// Альфа-аналог солида: тинт + ФАКТИЧЕСКАЯ α. +/// Непрерывный альфа-аналог солида: тинт + ФАКТИЧЕСКАЯ α. /// /// Продуктовый слой поверх строгого закона: потребитель всегда получает -/// пригодный ответ, и ответ никогда не врёт о цвете. +/// пригодный ответ без подмены цели клампом. Вещественная формула сохраняет +/// заданный цвет, а ошибка её binary64-вычисления ограничена доказанной выше +/// оценкой. Побайтовая эмиссия в hex/semantic использует отдельный проверяющий +/// sRGB8-путь с точным постусловием по байтам. #[derive(Debug, Clone, Copy, PartialEq)] pub struct AlphaAnalog { /// Кодированный тинт `[0,1]³`. pub tint: [f64; 3], - /// Фактическая α: запрошенная, если она разрешима, иначе минимально - /// разрешимая (композит при ней остаётся точно равным солиду). + /// Фактическая α: запрошенная, если она разрешима, иначе strict-binary64 + /// пол (не вещественный инфимум и не минимальная byte-grid α). pub alpha: f64, } @@ -189,25 +396,39 @@ pub struct AlphaAnalog { /// /// «Приблизить» можно двумя способами, и только один честен: кламп тинта при /// запрошенной α тихо сдвинул бы композит (система соврала бы о цвете — -/// запрещённая подмена), а подъём α до [`min_alpha_encoded`] сохраняет -/// композит ПОБАЙТНО равным солиду — двигается только прозрачность, и -/// фактическая α возвращается явно ([`AlphaAnalog::alpha`]). Запрошенная α -/// клампится в `[0,1]` (α=0 вырожденна и поднимается до α_min как «слишком -/// низкая»). +/// запрещённая подмена), а подъём α до strict-binary64 пола +/// [`min_alpha_encoded`] сохраняет исходную цель вещественной инверсии; в +/// binary64 остаётся только ограниченная ошибка округления, а не произвольный +/// цветовой сдвиг. Двигается прозрачность, и +/// фактическая α возвращается явно ([`AlphaAnalog::alpha`]). Запрошенная α вне +/// `[0,1]` отвергается: clamp скрыл бы ошибку вызывающего кода. `α=0` входит в +/// библиотечный домен и поднимается до strict-binary64 пола, если solid +/// отличается от фона; при `solid == bg` возвращается вырожденная пара +/// `tint=bg, α=0`. /// -/// `None` — только на входе вне домена (не кодированный цвет `[0,1]³` / -/// не конечный); для валидных цветов ответ существует всегда (в худшем -/// случае α=1, тинт=солид). +/// `None` — только на входе вне домена (цвет не конечен/не в `[0,1]³` либо +/// запрошенная α не конечна/не в `[0,1]`). Для валидного входа ответ существует +/// всегда (в худшем случае α=1, тинт=солид). pub fn resolve_alpha_analog( solid: [f64; 3], requested_alpha: f64, bg: [f64; 3], ) -> Option { - let floor = min_alpha_encoded(solid, bg)?; // None только на мусор-входах - if !requested_alpha.is_finite() { + if !requested_alpha.is_finite() || !(0.0..=1.0).contains(&requested_alpha) { return None; } - let alpha = requested_alpha.clamp(0.0, 1.0).max(floor); + let floor = min_alpha_encoded(solid, bg)?; // None только на мусор-входах + // Если запрошенная binary64-пара уже является точной обратной к нашему + // прямому ходу (включая честную gamut-границу), не поднимаем прозрачность. + if requested_alpha > 0.0 { + if let Some(tint) = invert_composite_encoded(solid, requested_alpha, bg) { + return Some(AlphaAnalog { + tint, + alpha: requested_alpha, + }); + } + } + let alpha = requested_alpha.max(floor); // При α == floor == 0 солид равен фону: любой видимый эффект отсутствует, // тинт = фон (инверсия при α=0 вырожденна — отвечаем без неё). if alpha == 0.0 { @@ -218,20 +439,207 @@ pub fn resolve_alpha_analog( Some(AlphaAnalog { tint, alpha }) } -/// Hex-обёртка [`resolve_alpha_analog`]: `(tint_hex, фактическая α)`. +/// Существование byte-тинта при фиксированной alpha проверяется только по +/// крайнему тинту нужного направления. Поканальный output монотонен по tint, а +/// соседние тинты отличаются до round не больше чем на `alpha ≤ 1`, поэтому +/// целевой байт нельзя «перепрыгнуть». +fn srgb8_target_is_feasible(solid: [u8; 3], alpha: f64, bg: [u8; 3]) -> bool { + (0..3).all(|channel| match solid[channel].cmp(&bg[channel]) { + std::cmp::Ordering::Equal => true, + std::cmp::Ordering::Greater => { + source_over_channel_srgb8(u8::MAX, alpha, bg[channel]) >= solid[channel] + } + std::cmp::Ordering::Less => { + source_over_channel_srgb8(u8::MIN, alpha, bg[channel]) <= solid[channel] + } + }) +} + +/// Первый `binary64`, на котором фактический reference-предикат допускает +/// byte-тинт. Положительные `f64` упорядочены своими битами, поэтому полный +/// lower-bound по `[0, 1]` занимает фиксированные 62 шага и не зависит от +/// эвристического epsilon или аналитического округления стенки. +fn first_srgb8_alpha(solid: [u8; 3], bg: [u8; 3]) -> f64 { + if solid == bg { + return 0.0; + } + let mut failing = 0.0_f64.to_bits(); + let mut passing = 1.0_f64.to_bits(); + // При alpha=0 композит равен bg при любом тинте, а при alpha=1 выбор + // tint=solid всегда достигает цели. После раннего `solid == bg` это строгая + // пара FAIL/PASS, необходимая двоичному lower-bound. + debug_assert!( + !srgb8_target_is_feasible(solid, 0.0, bg), + "разные solid/bg не могут быть достижимы при alpha=0: solid={solid:?}, bg={bg:?}" + ); + debug_assert!( + srgb8_target_is_feasible(solid, 1.0, bg), + "alpha=1 обязана достигать любой sRGB8-цели через tint=solid: solid={solid:?}, bg={bg:?}" + ); + + while passing - failing > 1 { + let middle = failing + (passing - failing) / 2; + if srgb8_target_is_feasible(solid, f64::from_bits(middle), bg) { + passing = middle; + } else { + failing = middle; + } + } + f64::from_bits(passing) +} + +/// Канонический byte-тинт при фиксированной alpha. +/// +/// Каналы независимы. Монотонным двоичным поиском находится весь непрерывный +/// диапазон byte-тинтов, которые production-композитор округляет в цель. Из +/// него выбирается байт с минимальной ошибкой до непрерывной инверсии; при +/// точном равенстве — меньший байт. Затем тот же композитор проверяет ответ. +fn srgb8_tint_at_alpha(solid: [u8; 3], alpha: f64, bg: [u8; 3]) -> Option<[u8; 3]> { + if !srgb8_target_is_feasible(solid, alpha, bg) { + return None; + } + let mut tint = [0_u8; 3]; + + for channel in 0..3 { + let solid_channel = solid[channel]; + let bg_channel = bg[channel]; + if solid_channel == bg_channel { + tint[channel] = bg_channel; + continue; + } + if alpha == 0.0 { + return None; + } + + let output = |candidate: u8| source_over_channel_srgb8(candidate, alpha, bg_channel); + let mut lo = 0_u16; + let mut hi = 256_u16; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if output(mid as u8) < solid_channel { + lo = mid + 1; + } else { + hi = mid; + } + } + if lo == 256 || output(lo as u8) != solid_channel { + return None; + } + let first = lo as u8; + + lo = u16::from(first); + hi = 256; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if output(mid as u8) <= solid_channel { + lo = mid + 1; + } else { + hi = mid; + } + } + let last = (lo - 1) as u8; + + let ideal = + f64::from(bg_channel) + (f64::from(solid_channel) - f64::from(bg_channel)) / alpha; + let floor = ideal.floor().clamp(f64::from(first), f64::from(last)) as u8; + let ceil = ideal.ceil().clamp(f64::from(first), f64::from(last)) as u8; + let error = |candidate: u8| { + (source_over_channel_value(candidate, alpha, bg_channel) - f64::from(solid_channel)) + .abs() + }; + tint[channel] = if error(floor).total_cmp(&error(ceil)).is_le() { + floor + } else { + ceil + }; + } + + let composite = [ + source_over_channel_srgb8(tint[0], alpha, bg[0]), + source_over_channel_srgb8(tint[1], alpha, bg[1]), + source_over_channel_srgb8(tint[2], alpha, bg[2]), + ]; + (composite == solid).then_some(tint) +} + +/// Эмиссионный sRGB8-путь альфа-аналога. +/// +/// Входные цвета сначала квантуются до той же byte-сетки, в которой будет +/// эмитирован тинт. Если запрошенная alpha допускает хотя бы один точный +/// byte-тинт, она сохраняется буквально. Иначе alpha поднимается до первого +/// `binary64`, который проходит тот же фиксированный Rust/JS reference- +/// композитор; непосредственный predecessor не проходит. Это отдельный, более +/// сильный контракт, чем continuous-инверсия [`resolve_alpha_analog`]. +/// +/// Недоменная запрошенная α и нарушение точного постусловия возвращаются как +/// `Err`: такой результат нельзя отдавать наружу. Для любого валидного домена +/// результат тотален — в худшем случае α=1 и byte-тинт равен цели. +pub(crate) fn resolve_alpha_analog_srgb8( + solid: [f64; 3], + requested_alpha: f64, + bg: [f64; 3], +) -> Result<([u8; 3], f64), String> { + if !requested_alpha.is_finite() || !(0.0..=1.0).contains(&requested_alpha) { + return Err(format!( + "requested_alpha вне конечного [0,1]: {requested_alpha}" + )); + } + let solid_srgb8 = encoded_to_srgb8(solid, "solid")?; + let bg_srgb8 = encoded_to_srgb8(bg, "bg")?; + if let Some(tint) = srgb8_tint_at_alpha(solid_srgb8, requested_alpha, bg_srgb8) { + verify_srgb8_alpha_analog(solid_srgb8, tint, requested_alpha, bg_srgb8)?; + return Ok((tint, requested_alpha)); + } + + let floor = first_srgb8_alpha(solid_srgb8, bg_srgb8); + // Запрошенная alpha уже не дала byte-тинт; тотальность канального поиска и + // монотонность reference-предиката означают, что первый PASS обязан быть + // строго правее неё. Равенство здесь выявляет рассогласование двух путей. + debug_assert!( + floor > requested_alpha, + "отвергнутая requested alpha обязана лежать ниже первого sRGB8 PASS: solid={solid_srgb8:?}, bg={bg_srgb8:?}, requested={requested_alpha}, floor={floor}" + ); + let tint = srgb8_tint_at_alpha(solid_srgb8, floor, bg_srgb8) + .ok_or_else(|| "первая sRGB8-alpha не дала допустимый byte-тинт".to_string())?; + verify_srgb8_alpha_analog(solid_srgb8, tint, floor, bg_srgb8)?; + Ok((tint, floor)) +} + +/// Глубинная проверка постусловия именно в эмитируемой byte-арифметике. +/// Отдельная функция нужна, чтобы тест мог доказать: неверная пара отвергается, +/// а не только что текущий солвер обычно выдаёт верную. +fn verify_srgb8_alpha_analog( + solid: [u8; 3], + tint: [u8; 3], + alpha: f64, + bg: [u8; 3], +) -> Result<(), String> { + let composite = composite_over_srgb8(tint, alpha, bg)?; + if composite != solid { + return Err(format!( + "alpha-analog не воспроизвёл sRGB8-цель: solid={solid:?}, tint={tint:?}, alpha={alpha}, bg={bg:?}, composite={composite:?}" + )); + } + Ok(()) +} + +/// Hex-обёртка эмиссионного sRGB8-резолвера: `(tint_hex, фактическая α)`. +/// Возвращённая пара побайтно воспроизводит +/// `solid_hex` через [`composite_over_srgb8`]; постусловие проверено до возврата. /// /// # Errors /// -/// `Err` при невалидном hex на любом входе. +/// `Err` при невалидном hex, неконечной/внедиапазонной запрошенной α либо при +/// нарушении точного sRGB8-постусловия (защитная ветка против численного дрейфа). pub fn resolve_alpha_analog_hex( solid_hex: &str, requested_alpha: f64, bg_hex: &str, -) -> Result, String> { +) -> Result<(String, f64), String> { let solid = srgb_encoded_from_hex(solid_hex)?; let bg = srgb_encoded_from_hex(bg_hex)?; - Ok(resolve_alpha_analog(solid, requested_alpha, bg) - .map(|a| (hex_from_srgb_encoded(a.tint), a.alpha))) + let (tint, alpha) = resolve_alpha_analog_srgb8(solid, requested_alpha, bg)?; + Ok((hex_from_srgb8(tint), alpha)) } #[cfg(test)] @@ -273,6 +681,189 @@ mod tests { } } + /// Контрпример на точной половине байта: source-over reference считает + /// каналы в шкале 0..255, поэтому `250 × 0.122 = 30.5` даёт 31. + /// Нормализация через `(250/255)×255` теряла tie и давала 30. + #[test] + fn emitted_composite_uses_byte_reference_arithmetic_at_half_tie() { + assert_eq!( + composite_hex("#C0B2FA", 0.122, "#000000").unwrap(), + "#17161F" + ); + } + + /// Порядок source-over обязан совпадать с `effective-bg.js`. Affine- + /// reference вычисляет `5 + 0.1·(0−5) = 4.5` и округляет канал в 5. + #[test] + fn source_over_half_seam_matches_the_official_js_operation_order() { + assert_eq!(composite_hex("#000505", 0.1, "#050505").unwrap(), "#050505"); + let (tint, actual) = resolve_alpha_analog_hex("#040505", 0.1, "#050505").unwrap(); + assert!( + actual > 0.1, + "запрошенная пара не воспроизводит красный байт 4" + ); + assert_eq!(composite_hex(&tint, actual, "#050505").unwrap(), "#040505"); + } + + /// Expanded-форма давала PASS→FAIL→PASS на трёх соседних `binary64`, что + /// делало обычный lower-bound ложным. Affine-reference монотонен. + #[test] + fn source_over_reference_is_monotone_across_the_known_ulp_seam() { + let centre = 0.812_992_125_984_252_f64; + let alphas = [ + f64::from_bits(centre.to_bits() - 1), + centre, + f64::from_bits(centre.to_bits() + 1), + ]; + let outputs = alphas.map(|alpha| source_over_channel_srgb8(255, alpha, 1)); + assert!( + outputs.windows(2).all(|pair| pair[0] <= pair[1]), + "{outputs:?}" + ); + } + + /// Теорема для всей одноканальной области известной α: эталон считается + /// целыми как `(122·t + 878·b)/1000`, поэтому тест не повторяет f64-путь + /// production-кода и ловит нормализацию через `/255·255` на всех half-tie. + #[test] + fn byte_reference_alpha_0122_matches_exact_rational_for_all_channel_pairs() { + for tint in u8::MIN..=u8::MAX { + for bg in u8::MIN..=u8::MAX { + let got = composite_over_srgb8([tint, 0, 0], 0.122, [bg, 0, 0]).unwrap()[0]; + let numerator = 122_u32 * u32::from(tint) + 878_u32 * u32::from(bg); + let expected = ((numerator + 500) / 1_000) as u8; + assert_eq!(got, expected, "tint={tint}, bg={bg}"); + } + } + } + + /// Конечная sRGB8-теорема альфа-аналога: для каждой из 65 536 пар + /// `(solid, bg)` и каждой представительной запрошенной α проверяющий + /// production-резолвер обязан вернуть тинт, который публичный byte- + /// композитор складывает ТОЧНО в исходный solid. `near_one` — соседнее + /// представимое число перед `1.0`; оно закрывает минимальный запас до + /// половины байта, где float-дрейф опаснее всего. + #[test] + fn srgb8_alpha_analog_is_exact_for_every_channel_pair() { + let near_one = f64::from_bits(1.0_f64.to_bits() - 1); + for requested_alpha in [0.0, 0.01, 0.122, 0.5, 0.9, near_one, 1.0] { + for solid in u8::MIN..=u8::MAX { + for bg in u8::MIN..=u8::MAX { + let solid_encoded = [f64::from(solid) / 255.0; 3]; + let bg_encoded = [f64::from(bg) / 255.0; 3]; + let requested_is_feasible = + srgb8_tint_at_alpha([solid; 3], requested_alpha, [bg; 3]).is_some(); + let (tint, actual_alpha) = + resolve_alpha_analog_srgb8(solid_encoded, requested_alpha, bg_encoded) + .unwrap_or_else(|error| { + panic!( + "solid={solid}, bg={bg}, requested={requested_alpha}: {error}" + ) + }); + let got = composite_over_srgb8(tint, actual_alpha, [bg; 3]) + .expect("резолвер возвращает α в [0,1]"); + assert_eq!( + got, [solid; 3], + "solid={solid}, bg={bg}, requested={requested_alpha}, actual={actual_alpha}, tint={tint:?}" + ); + if requested_is_feasible { + assert_eq!( + actual_alpha.to_bits(), + requested_alpha.to_bits(), + "разрешимая requested alpha была изменена: solid={solid}, bg={bg}" + ); + } else { + assert!(actual_alpha > requested_alpha); + let predecessor = f64::from_bits(actual_alpha.to_bits() - 1); + assert!( + srgb8_tint_at_alpha([solid; 3], predecessor, [bg; 3]).is_none(), + "actual alpha не минимальна: solid={solid}, bg={bg}, actual={actual_alpha}" + ); + } + } + } + } + } + + /// Два контрпримера прежнего continuous-floor: byte-grid округление уже + /// делает цель точной, поэтому прозрачность нельзя увеличивать. + #[test] + fn byte_grid_resolver_preserves_every_already_feasible_requested_alpha() { + let tiny_red_alpha = 0.5 / 255.0; + let (tiny_tint, tiny_actual) = + resolve_alpha_analog_hex("#010000", tiny_red_alpha, "#000000").unwrap(); + assert_eq!(tiny_tint, "#FF0000"); + assert_eq!(tiny_actual.to_bits(), tiny_red_alpha.to_bits()); + assert_eq!( + composite_hex(&tiny_tint, tiny_actual, "#000000").unwrap(), + "#010000" + ); + + let (tint, actual) = resolve_alpha_analog_hex("#1F0000", 0.12, "#000000").unwrap(); + assert_eq!(tint, "#FF0000"); + assert_eq!(actual.to_bits(), 0.12_f64.to_bits()); + assert_eq!(composite_hex(&tint, actual, "#000000").unwrap(), "#1F0000"); + } + + /// Валидная, но заведомо слишком малая alpha не должна переполнять + /// целочисленную реконструкцию ни в debug, ни в release. Она поднимается до + /// первой разрешимой sRGB8-точки и сохраняет целевой байт. + #[test] + fn byte_grid_resolver_handles_tiny_normal_and_subnormal_alpha() { + for requested in [2_f64.powi(-100), f64::from_bits(1)] { + let (tint, actual) = resolve_alpha_analog_hex("#010000", requested, "#000000").unwrap(); + assert!(actual > requested); + assert_eq!(composite_hex(&tint, actual, "#000000").unwrap(), "#010000"); + let predecessor = f64::from_bits(actual.to_bits() - 1); + assert!(srgb8_tint_at_alpha([1, 0, 0], predecessor, [0; 3]).is_none()); + } + } + + /// Исчерпывающий одноканальный сертификат reference-предиката: первый + /// `binary64` проходит, непосредственный predecessor — нет. + #[test] + fn byte_grid_alpha_floor_is_first_passing_for_every_channel_pair() { + for solid in u8::MIN..=u8::MAX { + for bg in u8::MIN..=u8::MAX { + let target = [solid, bg, bg]; + let background = [bg; 3]; + let floor = first_srgb8_alpha(target, background); + assert!( + srgb8_tint_at_alpha(target, floor, background).is_some(), + "solid={solid}, bg={bg}, floor={floor} не проходит" + ); + if solid == bg { + assert_eq!(floor.to_bits(), 0.0_f64.to_bits()); + } else { + let predecessor = f64::from_bits(floor.to_bits() - 1); + assert!( + srgb8_tint_at_alpha(target, predecessor, background).is_none(), + "solid={solid}, bg={bg}: predecessor={predecessor} тоже проходит" + ); + } + } + } + } + + /// Контрпример прежнего смешения доменов: `0.25/255` квантуется в byte 0, + /// но инверсия НЕквантованной цели при α=0.5 дала tint byte 1, чей + /// production-композит равен 1. Эмиссионный путь обязан сначала квантовать + /// цель, а глубинная проверка — отвергать заведомо неверную пару. + #[test] + fn srgb8_resolver_quantises_target_before_inversion_and_guard_rejects_drift() { + let off_grid_solid = [0.25 / 255.0; 3]; + let resolved: Result<([u8; 3], f64), String> = + resolve_alpha_analog_srgb8(off_grid_solid, 0.5, [0.0; 3]); + let (tint, alpha) = resolved.expect("валидный домен всегда имеет конечный sRGB8-ответ"); + assert_eq!(tint, [0; 3]); + assert_eq!(composite_over_srgb8(tint, alpha, [0; 3]).unwrap(), [0; 3]); + + assert!( + verify_srgb8_alpha_analog([0; 3], [1; 3], 0.5, [0; 3]).is_err(), + "пара из старого off-grid пути обязана быть отвергнута" + ); + } + /// Обратный ход на живых парах: восстановленный тинт отклоняется от /// канонического не более чем на границу квантования 0.5/(255·α) на канал, /// а его композит воспроизводит опорный hex побайтно (что и является @@ -293,18 +884,19 @@ mod tests { (tint[c] - true_tint[c]).abs() ); } - // Продуктовая гарантия: композит восстановленного тинта == опорный hex. - let recomposed = hex_from_srgb_encoded(composite_over_encoded(tint, *alpha, b)); + // Продуктовая гарантия проверяется production byte-композитором, + // а не повторением непрерывной алгебры инверсии. + let recomposed = composite_hex_from_encoded(tint, *alpha, b).unwrap(); assert_eq!(&recomposed, solid, "{solid}@{alpha}: re-композит разошёлся"); } } - /// Теорема тождества на непрерывных значениях: инверсия прямого хода - /// возвращает исходный тинт с машинной точностью для любых α ≥ α_min — - /// значит контраст альфа-аналога на каноническом фоне тождественен солиду - /// по построению. Свип — детерминированная сетка кодированных значений. + /// Алгебраическое тождество на непрерывных значениях с явной binary64- + /// границей ошибки. Прямой ход содержит три базовые операции, обратный — + /// ещё три; стандартная first-order оценка каждой правильно округлённой + /// операции даёт консервативные `8·ε/α` на восстановленный канал. #[test] - fn inversion_identity_is_exact_on_continuous_values() { + fn inversion_identity_respects_derived_binary64_error_bound() { let grid: Vec = (0..=10).map(|i| f64::from(i) / 10.0).collect(); for &tr in &grid { for &tb in &grid { @@ -312,16 +904,16 @@ mod tests { let tint = [tr, 0.5, tb]; let bg = [br, 0.25, 0.9]; for alpha in [0.05, 0.2, 0.5, 0.85, 1.0] { - let solid = composite_over_encoded(tint, alpha, bg); - let back = invert_composite_encoded(solid, alpha, bg) - .expect("прямой ход всегда обратим при той же α"); + let solid = composite_over_encoded_unchecked(tint, alpha, bg); + let back = invert_composite_encoded(solid, alpha, bg).unwrap_or_else(|| { + panic!( + "прямой ход не обратим: tint={tint:?}, bg={bg:?}, alpha={alpha}, solid={solid:?}" + ) + }); for c in 0..3 { - assert!( - (back[c] - tint[c]).abs() < 1e-9, - "identity: канал {c}: {} != {}", - back[c], - tint[c] - ); + let error = (back[c] - tint[c]).abs(); + let bound = 8.0 * f64::EPSILON / alpha; + assert!(error <= bound, "канал {c}: error={error} > {bound}"); } } } @@ -329,34 +921,62 @@ mod tests { } } - /// Аналитика α_min: на границе инверсия разрешима, чуть ниже — нет. - /// Свип по сетке пар (solid, bg), исключая вырожденный случай solid == bg - /// (там α_min = 0 и «чуть ниже» не существует). + /// Полный одноканальный sRGB8-домен фиксирует точное определение пола: + /// возвращённый `binary64` проходит строгую инверсию, а непосредственно + /// предшествующий — нет. Проверка predecessor принципиальна: округлённый + /// аналитический кандидат нередко проходит вместе с несколькими числами + /// перед ним, поэтому простого `candidate is feasible` недостаточно. #[test] - fn min_alpha_is_the_exact_feasibility_boundary() { - let grid: Vec = (0..=8).map(|i| f64::from(i) / 8.0).collect(); - for &s in &grid { - for &b in &grid { - if (s - b).abs() < 1e-15 { + fn min_alpha_is_first_passing_binary64_for_every_srgb8_channel_pair() { + for solid_byte in u8::MIN..=u8::MAX { + for bg_byte in u8::MIN..=u8::MAX { + let s = f64::from(solid_byte) / 255.0; + let b = f64::from(bg_byte) / 255.0; + let solid = [s, 0.25, 0.75]; + let bg = [b, 0.25, 0.75]; + let floor = min_alpha_encoded(solid, bg).expect("sRGB8 всегда в домене"); + + if solid_byte == bg_byte { + assert_eq!(floor.to_bits(), 0.0_f64.to_bits()); continue; } - let solid = [s, 0.3, 0.7]; - let bg = [b, 0.3, 0.7]; - let a_min = min_alpha_encoded(solid, bg).expect("вход в домене"); + assert!( - invert_composite_encoded(solid, a_min.max(1e-9), bg).is_some(), - "solid={s}, bg={b}: неразрешимо на собственной α_min={a_min}" + invert_composite_strict(solid, floor, bg).is_some(), + "solid={solid_byte}, bg={bg_byte}: floor={floor} не проходит" + ); + let predecessor = f64::from_bits(floor.to_bits() - 1); + assert!( + invert_composite_strict(solid, predecessor, bg).is_none(), + "solid={solid_byte}, bg={bg_byte}: predecessor={predecessor} тоже проходит; floor={floor} не минимален" ); - if a_min > 1e-6 { - assert!( - invert_composite_encoded(solid, a_min * 0.999, bg).is_none(), - "solid={s}, bg={b}: разрешимо НИЖЕ α_min={a_min} — граница не точна" - ); - } } } } + /// `min_alpha_encoded` нельзя называть минимумом sRGB8-композита: + /// финальное округление расширяет достижимое множество на половину кода. + /// Контрпример также защищает смысл `alphaCoerced`: это сознательно + /// отдельный strict-binary64 пол. + #[test] + fn strict_binary64_floor_is_not_the_byte_grid_minimum() { + let solid = [1.0 / 255.0, 0.0, 0.0]; + let bg = [0.0; 3]; + let strict_floor = min_alpha_encoded(solid, bg).unwrap(); + let byte_alpha = 0.5 / 255.0; + + assert_eq!(strict_floor, 1.0 / 255.0); + assert!(byte_alpha < strict_floor); + assert_eq!( + composite_over_srgb8([255, 0, 0], byte_alpha, [0; 3]).unwrap(), + [1, 0, 0] + ); + assert!( + invert_composite_encoded(solid, byte_alpha, bg).is_none(), + "byte достижим округлением, но continuous-тинт был бы вне gamut" + ); + } + /// Вырожденные α честно отвергаются, кламп не подменяет ответ. #[test] fn degenerate_alpha_is_rejected_not_clamped() { @@ -369,10 +989,20 @@ mod tests { assert_eq!(invert_composite_encoded(s, 1.0, b), Some(s)); } - /// Продуктовый резолвер никогда не врёт о цвете: при разрешимой + /// Выход из gamut даже на доли триллионной не является «машинным шумом»: + /// глобальный epsilon раньше превращал неразрешимую пару в ложный ответ. + #[test] + fn inversion_never_expands_gamut_with_epsilon() { + let alpha = 1.0 - 5e-13; + assert!(alpha < min_alpha_encoded([0.0; 3], [1.0; 3]).unwrap()); + assert!(invert_composite_encoded([0.0; 3], alpha, [1.0; 3]).is_none()); + } + + /// Непрерывный резолвер не подменяет целевой цвет: при разрешимой /// запрошенной α возвращает её саму; при неразрешимой — поднимает α ровно - /// до α_min, и композит остаётся ТОЧНО равным солиду (двигается - /// прозрачность, не цвет). Кламп-подмена тинта не происходит. + /// до α_min. Ошибка binary64-композита остаётся внутри доказанной оценки + /// (двигается прозрачность, не целевой цвет). Побайтовый постусловный тест живёт отдельно в + /// `srgb8_alpha_analog_is_exact_for_every_channel_pair`. #[test] fn resolver_moves_alpha_never_the_colour() { let grid: Vec = (0..=8).map(|i| f64::from(i) / 8.0).collect(); @@ -382,24 +1012,35 @@ mod tests { let bg = [b, 0.4, 0.6]; let floor = min_alpha_encoded(solid, bg).expect("в домене"); for requested in [0.0, 0.05, 0.3, 0.9, 1.0] { + let requested_is_exact = + requested > 0.0 && invert_composite_encoded(solid, requested, bg).is_some(); let a = resolve_alpha_analog(solid, requested, bg).expect("в домене"); - // Фактическая α: запрошенная, если разрешима, иначе ровно α_min. - let want = requested.max(floor); - assert!( - (a.alpha - want).abs() < 1e-12, + // Фактическая α: запрошенная, если строгая или побитно-точная + // граничная инверсия разрешима; иначе ровно conservative floor. + let want = if requested_is_exact { + requested + } else { + requested.max(floor) + }; + assert_eq!( + a.alpha.to_bits(), + want.to_bits(), "solid={s},bg={b},req={requested}: α={} != {want}", a.alpha ); - // Композит НИКОГДА не отклоняется от солида. + // Отклонение — только ограниченная ошибка binary64, а не + // результат клампа или смены целевого цвета. let c = if a.alpha == 0.0 { bg // вырожденный случай solid==bg } else { - composite_over_encoded(a.tint, a.alpha, bg) + composite_over_encoded_unchecked(a.tint, a.alpha, bg) }; for ch in 0..3 { + let error = (c[ch] - solid[ch]).abs(); + let bound = 8.0 * f64::EPSILON; assert!( - (c[ch] - solid[ch]).abs() < 1e-9, - "solid={s},bg={b},req={requested}: композит уехал на канале {ch}" + error <= bound, + "solid={s},bg={b},req={requested}: канал {ch}, error={error} > {bound}" ); } } @@ -418,6 +1059,23 @@ mod tests { } } + /// Обе публичные прямые границы одинаково отвергают мусор в debug/release; + /// непрерывная алгебра не доступна как panic/clamp-лазейка. + #[test] + fn public_compositors_reject_invalid_numeric_inputs() { + let valid = [0.25, 0.5, 0.75]; + for alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.1] { + assert!(composite_over_encoded(valid, alpha, valid).is_err()); + assert!(composite_over_srgb8([1, 2, 3], alpha, [4, 5, 6]).is_err()); + } + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.1] { + let mut rgb = valid; + rgb[0] = bad; + assert!(composite_over_encoded(rgb, 0.5, valid).is_err()); + assert!(composite_over_encoded(valid, 0.5, rgb).is_err()); + } + } + /// Все три hex-обёртки публичной поверхности: roundtrip на живой Figma-паре, /// плюс честный Err (не паника) на невалидном hex — .expect в min_alpha_hex /// недостижим, парсинг падает раньше через `?`. @@ -431,10 +1089,10 @@ mod tests { // min_alpha_hex: для равных цветов пол = 0; для контрастной пары > 0. assert_eq!(min_alpha_hex("#FFFFFF", "#FFFFFF").unwrap(), 0.0); assert!(min_alpha_hex("#101012", "#FFFFFF").unwrap() > 0.9); - // resolve_alpha_analog_hex: неразрешимая α поднимается, композит равен солиду. + // Hex-путь: неразрешимая α поднимается, а итоговый sRGB8-композит + // побайтно равен солиду. let (tint2, actual) = resolve_alpha_analog_hex("#101012", 0.05, "#FFFFFF") - .expect("валидный hex") - .expect("цвета в домене"); + .expect("валидный домен всегда имеет конечный ответ"); assert!(actual > 0.05, "α обязана подняться до разрешимой"); assert_eq!(composite_hex(&tint2, actual, "#FFFFFF").unwrap(), "#101012"); // Невалидный hex — Err на каждой обёртке (никаких паник). @@ -445,19 +1103,52 @@ mod tests { ] { assert!(f.is_some(), "невалидный hex обязан дать Err"); } + + // Ошибка вызывающего кода не должна выглядеть как физическая + // неразрешимость корректной пары цветов. + for bad in [ + f64::NAN, + f64::NEG_INFINITY, + f64::INFINITY, + -f64::EPSILON, + 1.0 + f64::EPSILON, + ] { + assert!( + invert_composite_hex("#808080", bad, "#FFFFFF").is_err(), + "alpha={bad} обязана дать Err, а не Ok(None)" + ); + } + assert_eq!( + invert_composite_hex("#808080", 0.0, "#FFFFFF").unwrap(), + None, + "нулевая alpha валидна, но обратный ход вырожден" + ); } - /// Резолвер отвергает только мусор; NaN-α — тоже мусор, не «поднять до α_min». + /// Резолвер строго отвергает α вне `[0,1]`: ни NaN, ни конечное значение за + /// границей не должны превращаться clamp-ом в правдоподобный ответ. #[test] - fn resolver_rejects_only_out_of_domain() { + fn resolver_rejects_every_out_of_domain_alpha() { let ok = [0.5, 0.5, 0.5]; assert!(resolve_alpha_analog([1.5, 0.0, 0.0], 0.5, ok).is_none()); - assert!(resolve_alpha_analog(ok, f64::NAN, ok).is_none()); - // Запрошенная α вне [0,1] клампится, не отвергается (пригодный ответ). - assert_eq!( - resolve_alpha_analog(ok, 5.0, ok).map(|a| a.alpha), - Some(1.0) - ); + for bad in [ + f64::NAN, + f64::NEG_INFINITY, + f64::INFINITY, + -f64::EPSILON, + 1.0 + f64::EPSILON, + -1.0, + 5.0, + ] { + assert!( + resolve_alpha_analog(ok, bad, ok).is_none(), + "requested α={bad} обязана быть отвергнута" + ); + assert!( + resolve_alpha_analog_hex("#808080", bad, "#808080").is_err(), + "hex-граница обязана вернуть Err для α={bad}" + ); + } } /// Домен ядра закреплён: внегамутные и неконечные входы отвергаются @@ -499,7 +1190,7 @@ mod tests { let tint = [0.4, 0.6, 0.2]; let bg = [1.0, 1.0, 1.0]; for alpha in [0.08, 0.2, 0.5] { - let solid = composite_over_encoded(tint, alpha, bg); + let solid = composite_over_encoded_unchecked(tint, alpha, bg); let shifted = [solid[0] + 0.5 / 255.0, solid[1], solid[2]]; let back = invert_composite_encoded(shifted, alpha, bg) .expect("сдвиг на пол-кода не выбивает из гамута на этих значениях"); diff --git a/crates/labcolors-core/src/cleanliness.rs b/crates/labcolors-core/src/cleanliness.rs index b3dedce3..83b7f931 100644 --- a/crates/labcolors-core/src/cleanliness.rs +++ b/crates/labcolors-core/src/cleanliness.rs @@ -1,42 +1,59 @@ -//! Порт финального «Закона Грязи» (Muddiness Law) системы `labcolors`. +//! Замороженная legacy research-coordinate системы `labcolors`. +//! +//! Исторические имена модуля и функций (`cleanliness`, `muddiness`, `drab`, +//! `n_pure`) — только идентификаторы API. Результат — **experimental compatibility proxy**, +//! а не observer-validated cleanliness/murkiness, +//! эстетическая оценка, human verdict или product contract. Компилятор и +//! runtime-resolver токенов эту координату не используют. +//! +//! Формула и frozen conformance corpus воспроизводимы; это не валидирует +//! человеческий смысл названий. `C0`, `JND`, `B0` и `BW` остаются legacy +//! compatibility constants: опубликованный вывод их точных Oklab-значений и +//! универсальная применимость не установлены. Hanning-форма `hue_weight` — +//! эвристика; эффект Бецольда—Брюкке её не выводит. `H_Y_DEG` воспроизводит +//! арифметическое преобразование выбранного 578 nm reference в Oklab, но не +//! является универсальным unique-yellow наблюдателя. +//! +//! Полный эпистемический статус и владельцы миграции: `docs/empirical-inventory.md` +//! (M-01…M-14) и Issue #231. //! //! # Уравнения //! //! ```text //! raw = neutral_gate(C) * hue_weight(h) * depth_mod(L, C, h) -//! mud = raw ← параметр-свободная монотонная нормировка (Zone B, 2026-06-30) +//! mud = raw ← замороженный legacy-идентификатор результата //! ``` //! //! Platt-звено (логарифм raw + eps → sigmoid с двумя подогнанными скалярами) **удалено полностью** //! в Zone B slice 3: оба скаляра (M-07/M-08) подогнаны на авторском датасете 738 меток //! (наблюдатель-фит) и нарушали инвариант «ZERO observer-fit» (North). mud = raw_chromatic напрямую: //! - параметр-свободно (нет новых констант); -//! - монотонно (произведение монотонных сигмоидных факторов ∈ \[0,1\]); //! - ограничено \[0,1\] точно (каждый множитель ∈ \[0,1\]); -//! - JND-относительно по конструкции (gate N(C) выровнен по M-02 JND). +//! - масштаб logistic gate задаётся замороженной шириной M-02; это не заявление +//! о perceptual threshold или универсальном JND. //! //! Бывший light-escape порог M-03 (DECLARED-CALIBRATION скаляр) и его escape-член //! в `neutral_gate` **удалены целиком** (Zone B, 2026-07-01): escape-член протекал //! ось L в функцию, документированную как чисто-хроматический hue-agnostic гейт — //! нарушение независимости осей. `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)`. //! -//! # Инвентарь параметров (раздел Muddiness Law) +//! # Инвентарь legacy-параметров //! //! | const | mud-id | статус | значение | //! |-----------------|--------|--------------------------------|-----------------------------| -//! | `C0` | M-01 | cited-and-kept | 0.0395 (граница серого sRGB; Evans/Xie-Fairchild yellow zero-grayness frontier) | -//! | `JND` | M-02 | cited-and-kept | 0.01228 (Oklab chroma JND; Oklab perceptual measurement) | -//! | `B0` | M-04 | cited-measured | 0.036 (центр диапазона [0.030, 0.044]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | -//! | `BW` | M-05 | cited-measured | 0.017 (центр диапазона [0.013, 0.020]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | +//! | `C0` | M-01 | Indeterminate provenance | 0.0395 frozen compatibility value; exact Oklab derivation not established | +//! | `JND` | M-02 | universal JND Rejected; value Indeterminate | 0.01228 frozen gate width; reference point/domain not specified | +//! | `B0` | M-04 | Rejected provenance; Indeterminate value | 0.036 frozen positive-b gate centre; cited Oklab range was not derived by the named sources | +//! | `BW` | M-05 | Rejected provenance; Indeterminate value | 0.017 frozen positive-b gate width; cited Oklab range was not derived by the named sources | //! | ~~`CAL_EPS`~~ | M-06 | УДАЛЁН (Zone B slice 3) | был 0.01; log-регуляризатор для Platt — более не нужен | //! | ~~`CAL_T`~~ | M-07 | УДАЛЁН (Zone B slice 3) | был 2.356978; Platt-скаляр, подогнанный на 738 авторских метках — нарушал ZERO observer-fit | //! | ~~`CAL_B`~~ | M-08 | УДАЛЁН (Zone B slice 3) | был 6.445168; Platt-смещение — нарушало ZERO observer-fit | //! | ~~`M_W`~~ | M-09 | УДАЛЁН (Front B, 2026-07-04) | был 0.181527; полуширина доверительного поля confidence-слоя — субъективная калибровка владельца без модели надёжности, вне пути эмиссии — удалён целиком | //! | ~~`KAPPA_CORE`~~ | M-10 | УДАЛЁН (Front B, 2026-07-04) | был 0.34; потолок уверенности ядра — та же субъективная калибровка — удалён вместе с confidence-слоем | //! | ~~`KAPPA_INTERIOR`~~ | M-11 | УДАЛЁН (Front B, 2026-07-04) | был 0.10; пол уверенности спорной полосы — удалён вместе с confidence-слоем | -//! | `H_Y_DEG` | M-12 | cited-derived (Zone B slice 5) | 90.4011° — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65, XYZ→Oklab напрямую); hue_weight = (1+cos(h−H_Y))/2 | +//! | `H_Y_DEG` | M-12 | arithmetic admitted; observer claim Rejected | 90.4011° — выбранный 578 nm reference → Oklab; не universal unique yellow и не вывод Hanning-семантики | //! | ~~`W_HUE[8]`~~ | M-12 | УДАЛЁН (Zone B slice 4) | был: подогнанный K=3 Fourier-вектор логистической регрессии — нарушал ZERO observer-fit | -//! | `CUSP_L_TABLE` | M-13 | cited-and-kept | (чистая геометрия гамута Oklab, верифицирована до f64 — kept as-is) | +//! | `CUSP_L_TABLE` | M-13 | geometry admitted | геометрия гамута Oklab; не человеческая граница cleanliness | //! | ~~`CEIL_N_TABLE`~~ | M-14 | УДАЛЕНА (Zone B slice 4) | была: Fourier CEIL_N, подогнана на датасете v3 — более не нужна после BB-замены | #![allow(clippy::excessive_precision)] @@ -92,25 +109,28 @@ pub(crate) static CUSP_L_TABLE: [f64; 361] = [ // CEIL_N_TABLE удалена (Zone B slice 4, 2026-06-30): // K=3 Fourier-базис CEIL_N использовался в hue_basis() для dot-product c W_HUE. -// После замены hue_weight на Hanning-окно BB ни hue_basis, ни CEIL_N_TABLE +// После замены hue_weight на замороженное Hanning-окно ни hue_basis, ни CEIL_N_TABLE // не нужны в продуктивном коде. // High-precision frozen parameter constants pub const C0: f64 = 0.0395000000000000; -/// JND reuse rationale: this constant is the cited Oklab chroma just-noticeable-difference -/// (Oklab perceptual measurement, M-02 cited-and-kept). It is used as the sigmoid gate width -/// throughout the module because JND is the natural perceptual scale for a logistic boundary — -/// a ±1 JND band captures the transition from sub-threshold to supra-threshold chroma presence. -/// No new fit is performed; this is the same cited value reused as a scale parameter. -/// The applicability of a chroma JND as the gate width is a design assumption; if a future -/// study finds a different gate width is warranted, M-02 should be split into separate cited -/// values (one for detection threshold, one for gate width). Flagged OPEN as an assumption. +/// Frozen width of the legacy sigmoid gate (M-02). +/// +/// The historical name is retained for API compatibility. A universal Oklab +/// just-noticeable difference is rejected: no reference point, direction, +/// viewing protocol, or observer population establishes this exact value. +/// Therefore this constant must not be interpreted as a detection threshold. +/// See `docs/empirical-inventory.md` and Issue #242. pub const JND: f64 = 0.0122779190541810; // M-03 (former light-escape calibration threshold, DECLARED-CALIBRATION) removed // entirely (Zone B, 2026-07-01): its escape term leaked the lightness axis into // what is documented as a hue-agnostic, chroma-only gate (see `neutral_gate` above). -pub const B0: f64 = 0.036; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.030, 0.044] -pub const BW: f64 = 0.017; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.013, 0.020] +/// Frozen centre of the legacy positive-b gate (M-04). +/// The former cited-measured provenance claim is rejected; this is compatibility state. +pub const B0: f64 = 0.036; +/// Frozen width of the legacy positive-b gate (M-05). +/// The former cited-measured provenance claim is rejected; this is compatibility state. +pub const BW: f64 = 0.017; // CAL_EPS / CAL_T / CAL_B удалены (Zone B slice 3, 2026-06-30): // Platt-звено sigmoid(CAL_T*ln(raw+eps)+CAL_B) было observer-fit на 738 авторских метках // (нарушение ZERO observer-fit, North). Заменено параметр-свободным mud = raw_chromatic. @@ -121,14 +141,15 @@ pub const BW: f64 = 0.017; // cited-measured central (Newhall-Nickerson-Judd 194 // W_HUE[8] удалён (Zone B slice 4, 2026-06-30): // Подогнанный вектор K=3 Фурье-регрессии (логистическая регрессия на 738 авторских -// метках, M-12) заменён выведенным Hanning-окном Бецольда-Брюкке. +// метках, M-12) заменён замороженной параметр-свободной Hanning-эвристикой. // Формула: hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 -// Провенанс: инвариантная точка жёлтого BB 578nm (Purdy 1937; Jacobs & Wascher 1967, -// J. Opt. Soc. Am. 57, 1155–1156) × Oklab (Ottosson 2020). +// H_Y_DEG воспроизводит арифметическое преобразование выбранного 578nm reference +// в Oklab. Эффект Бецольда—Брюкке не выводит эту Hanning-форму и не придаёт ей +// универсальный observer meaning. // Инварианты: hue_weight(H_Y_DEG) = 1.0 точно; hue_weight(H_Y_DEG ± 180°) = 0.0 точно; // строго монотонное убывание при |h − H_Y_DEG| растёт от 0 до 180°. -/// Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° наблюдатель, D65). +/// Oklab hue выбранного спектрального reference λ=578nm (CIE 1931 2° CMF). /// /// Вывод (Zone B slice 5, 2026-07-03 — исправление ошибочной деривации slice 4): /// 1. CMF при 578nm — линейная интерп. официальной CIE 1931 2° 5нм-таблицы: @@ -150,13 +171,13 @@ pub const BW: f64 = 0.017; // cited-measured central (Newhall-Nickerson-Judd 194 /// сохраняет оттенок: без клэмпа тот же XYZ давал h=69.7°, после клэмпа 96.9°. /// Значение 96.9172° было артефактом клэмпа поверх неверного XYZ. /// -/// Выбор λ=578nm: инвариантная точка жёлтого Бецольда-Брюкке (Purdy 1937, -/// Am. J. Psychol. 49, 313–315; подтверждено Jacobs & Wascher 1967, -/// J. Opt. Soc. Am. 57, 1155–1156). Прежняя цитата «Parry (1967) JOSA 57, +/// Арифметика выбранного λ=578nm допускается и воспроизводима. Заявление, что +/// это универсальный unique-yellow наблюдателя, отвергнуто: результат зависит +/// от наблюдателя, яркости и метода. Прежняя цитата «Parry (1967) JOSA 57, /// 1130–1134» не существует в литературе и удалена. /// /// Использование: центр Hanning-окна `hue_weight(h)` (M-12). -/// Константа выводная, не подогнана; воспроизводится скриптом деривации из CMF. +/// Число не подогнано; воспроизводится скриптом арифметического преобразования CMF. pub const H_Y_DEG: f64 = 90.4011; #[inline] @@ -181,18 +202,19 @@ pub fn cusp_l_of(h_deg: f64) -> f64 { y0 + fract * (y1 - y0) } -/// Depth term: how far below the hue's clean cusp the colour sits, normalized by cusp L. -/// Ranges from 0.0 (at/above cusp) to 1.0 (deep in the basement). +/// Geometric depth below the hue-dependent sRGB/Oklab gamut cusp, normalized by cusp L. +/// Ranges from 0.0 (at/above cusp) to 1.0 (furthest below it); no human meaning follows. pub fn depth_term(l: f64, h_deg: f64) -> f64 { let cusp_l = cusp_l_of(h_deg); let below = (cusp_l - l) / cusp_l.max(1e-6); below.clamp(0.0, 1.0) } -/// Hue-agnostic chroma-confidence gate: checks if the color is chromatic at all, or reads grey/beige. +/// Frozen hue-agnostic logistic coordinate over Oklab chroma. /// -/// `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)` — a pure JND-relative -/// chroma-presence gate. The former light-escape term (a product of two +/// `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)`. The parameter named +/// `jnd` is a compatibility width, not a detection probability or universal +/// perceptual threshold. The former light-escape term (a product of two /// sigmoids gated on lightness and chroma) depended on a DECLARED-CALIBRATION /// threshold (M-03, a Platt-fit scalar on the v3 dataset) and leaked the /// lightness axis into a function documented as hue-agnostic and chroma-only — @@ -201,44 +223,34 @@ pub fn neutral_gate(c: f64, c0: f64, jnd: f64) -> f64 { sigmoid((c - c0) / jnd) } -/// Opponent b-gate (warm/clean filter): gates the geometric depth term. Warm -> 1.0, cool -> 0.0. +/// Frozen positive-b logistic gate multiplied by the geometric depth term. +/// Its historical parameter values have no admitted human clean/dirty semantics. pub fn depth_mod(l: f64, c: f64, h_deg: f64, b0: f64, bw: f64) -> f64 { let dp = depth_term(l, h_deg); let s = sigmoid((b_of(c, h_deg) - b0) / bw); dp * s } -/// Весовой множитель оттенка — Hanning-окно Бецольда-Брюкке (Zone B slice 4, 2026-06-30). +/// Замороженная Hanning-эвристика над Oklab hue (Zone B slice 4, 2026-06-30). /// -/// Заменяет подогнанный вектор W_HUE\[8\] (M-12, удалён в Zone B slice 4) на выводную формулу. +/// Заменяет подогнанный вектор W_HUE\[8\] (M-12, удалён в Zone B slice 4) +/// параметр-свободной формулой. /// -/// # Вывод формулы +/// # Эпистемический статус /// -/// Эффект Бецольда-Брюкке — реальный психофизический феномен (уникальный жёлтый и его -/// инвариантная точка заземлены в этом же файле через Purdy 1937, см. [`H_Y_DEG`]): при -/// росте яркости оттенки смещаются к уникальному жёлтому (h_Y) и уникальному синему. -/// Косинусная модель плотности конвергенции ниже — ЭВРИСТИКА (параметрическая форма -/// Hanning-окна); железный первоисточник ИМЕННО ЭТОЙ формы не установлен. Прежняя ссылка -/// «Parry (1967) JOSA 57, 1130–1134» не существует в литературе (отозвана у [`H_Y_DEG`]) -/// и удалена. Производная смещения по Oklab-оттенку: -/// -/// dΔH_BB/dh = A_BB · cos(h − h_Y) -/// -/// — плотность конвергенции оттенков к h_Y. Максимальна при h = h_Y (все ближние оттенки -/// сходятся к уникальному жёлтому, зона максимальной «грязи»); обнуляется при |h − h_Y| = 90°; -/// минимальна при |h − h_Y| = 180° (противоположное направление). -/// -/// Нормируем в [0, 1]: +/// Эффект Бецольда—Брюкке реален, но не выводит эту косинусную функцию, +/// её центр или human cleanliness semantics. Формула ниже — frozen heuristic; +/// доказаны только её математические свойства. `H_Y_DEG` — арифметика выбранного +/// 578nm reference, не универсальный unique-yellow наблюдателя. /// /// hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 [Hanning-окно] /// /// # Провенанс констант /// -/// - `H_Y_DEG = 90.4011°` — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65). +/// - `H_Y_DEG = 90.4011°` — Oklab hue выбранного λ=578nm reference по CIE 1931 2° CMF. /// Вывод: CMF → XYZ → Oklab напрямую (Ottosson 2020) → atan2(b, a); без sRGB-клэмпа /// (клэмп не сохраняет hue — см. историю slice 4 в доке H_Y_DEG). -/// Цитата: Purdy (1937) — инвариантная точка жёлтого 578nm; Jacobs & Wascher (1967) -/// J. Opt. Soc. Am. 57, 1155–1156. +/// Это не observer-validated invariant; см. M-12 в empirical inventory. /// /// # Инварианты (проверены тестами) /// @@ -270,7 +282,7 @@ pub fn hue_weight(h_deg: f64) -> f64 { } } -/// Сырой хроматический счёт грязи (без масштабирования). +/// Сырая experimental compatibility proxy coordinate (legacy API identifier). /// /// raw = N(C) × hue_weight_BB(h) × depth_mod(L, C, h) /// @@ -282,29 +294,31 @@ pub fn raw_chromatic(l: f64, c: f64, h_deg: f64) -> f64 { cc * hw * dpm } -/// Параметр-свободная монотонная цена грязи (Zone B slices 3+4, 2026-06-30). +/// Frozen experimental compatibility proxy (Zone B slices 3+4, 2026-06-30). +/// +/// `mud = raw_chromatic(l, c, h)` is a deterministic product of three legacy factors: +/// `N(C) = sigmoid((C - C0) / JND)` — frozen logistic gate (M-01/M-02) +/// `hue_weight(h) = (1+cos(h-H_Y_DEG))/2` — Hanning heuristic (M-12) +/// `depth_mod(L, C, h)` — gamut geometry × positive-b gate (M-04/M-05) /// -/// mud = raw_chromatic(l, c, h) — прямое произведение трёх перцептивно-обоснованных -/// факторов из геометрии Oklab и поворота Бецольда-Брюкке: -/// N(C) = sigmoid((C - C0) / JND) — JND-взвешенное присутствие хромы (M-01/M-02) -/// hue_weight(h) = (1+cos(h−H_Y_DEG))/2 — Hanning-окно BB (M-12, Zone B slice 4) -/// depth_mod(L, C, h) — глубина под cusp-L, взвешенная b-гейтом (M-04/M-05) +/// The historical name does not make the output an observer-validated cleanliness, +/// murkiness, aesthetic, or production decision. The compiler/resolver does not use it. /// /// Свойства: -/// - Выводных констант: только H_Y_DEG = 90.4° (CIE D65 unique yellow 578nm, Purdy 1937) +/// - `H_Y_DEG` reproduces the selected 578nm-to-Oklab arithmetic /// - Нет подогнанных скаляров: W_HUE/CEIL_N_TABLE/CAL_T/CAL_B — все удалены /// - raw ∈ \[0,1\] точно (произведение ∈ \[0,1\] множителей) /// - Монотонно в C только на ТЁПЛОЙ полуплоскости (h∈[0°,180°], sin h ≥ 0): /// N(C) растёт, b-гейт depth_mod не убывает. На холодных (sin h < 0) b-гейт /// `sigmoid((c·sin h − B0)/BW)` убывает в C → НЕ глобально монотонно /// (запинено `property_invariants`: warm-инвариант + cool-characterization) -/// - JND-нормировано по конструкции через M-02 +/// - logistic width is the frozen M-02 compatibility value /// - .clamp(0.0, 1.0) — safety no-op при f64-округлении pub fn muddiness_oklch(l: f64, c: f64, h_deg: f64) -> f64 { raw_chromatic(l, c, h_deg).clamp(0.0, 1.0) } -/// Compute muddiness from linear sRGB values [r, g, b] in [0, 1]. +/// Compute the legacy experimental compatibility proxy from linear sRGB in [0, 1]. pub fn muddiness_from_linear_srgb(rgb: [f64; 3]) -> f64 { let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); let l = lab[0]; @@ -313,17 +327,17 @@ pub fn muddiness_from_linear_srgb(rgb: [f64; 3]) -> f64 { muddiness_oklch(l, c, h) } -/// Compute muddiness from an sRGB hex color string like "#6B6B2E". +/// Compute the legacy experimental compatibility proxy from an sRGB hex string. pub fn muddiness_from_hex(hex: &str) -> Result { let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; Ok(muddiness_from_linear_srgb(rgb)) } // ───────────────────────────────────────────────────────────────────────────── -// Drab defect head (Zone B slice 2 — second INDEPENDENT output) +// Legacy `drab` coordinate (Zone B slice 2 — second INDEPENDENT output) // // `n_pure` and `drab` are a SEPARATE defect axis from mud/muddiness_oklch. -// They share ONLY the cited gate constants C0 / JND as INPUTS. +// They share ONLY the frozen compatibility constants C0 / JND as INPUTS. // They are NEVER called inside `raw_chromatic`, `muddiness_oklch`, or any // other mud code path — the two axes are provably independent (the curl / // integrability two-axes result from the paradigm North). @@ -332,32 +346,34 @@ pub fn muddiness_from_hex(hex: &str) -> Result { // = 1 - sigmoid((C - C0) / JND) [= 1 - n_pure(C)] // // Constants reused (zero new parameters): -// C0 = 0.0395 (cited-and-kept, M-01, Evans/Xie-Fairchild yellow zero-grayness frontier) -// JND = 0.0122779190541810 (cited-and-kept, M-02, Oklab chroma JND) +// C0 = 0.0395 (M-01, provenance Indeterminate) +// JND = 0.0122779190541810 (M-02, universal JND Rejected) // -// Статус (актуально на 2026-07-01): W_HUE[8] удалён и заменён выведенным Hanning-окном -// Бецольда-Брюкке (Zone B slice 4, M-12 cited-derived, см. таблицу инвентаря выше); +// Статус (актуально на 2026-07-11): W_HUE[8] удалён и заменён замороженной +// Hanning-эвристикой (Zone B slice 4, M-12, см. таблицу инвентаря выше); // Platt CAL_T/CAL_B удалены (Zone B slice 3). Открытых наблюдатель-фит параметров // в этом блоке не осталось. // ───────────────────────────────────────────────────────────────────────────── -/// Pure chroma-presence gate: N_pure(C) = sigmoid((C - C0) / JND). +/// Historical `n_pure` coordinate: `sigmoid((C - C0) / JND)`. /// /// This is the shared gate that enters both mud (as `neutral_gate`) and drab /// (as 1 - N_pure). It is kept as a standalone `pub fn` so callers can /// compose the two heads independently without re-implementing the gate. /// -/// Parameters: cited-and-kept C0 (M-01) and JND (M-02). Zero new parameters. +/// The name is a compatibility identifier, not an observer estimate of purity. +/// Parameters are frozen C0 (M-01) and JND (M-02); zero new parameters. #[inline] pub fn n_pure(c: f64) -> f64 { sigmoid((c - C0) / JND) } -/// Drab defect head: D(C) = 1 − N_pure(C). +/// Historical `drab` coordinate: `D(C) = 1 - N_pure(C)`. /// -/// Measures chroma ABSENCE — the complement of the cited chroma-presence gate. +/// This is the arithmetic complement of the frozen `n_pure` logistic coordinate. /// Returns 1.0 for achromatic colours (C ≈ 0, deeply grey/beige) and 0.0 for -/// strongly chromatic colours (C >> C0). +/// strongly chromatic colours (C >> C0), but it is not an observer-validated +/// estimate of dullness, drabness, purity, or any other human judgement. /// /// Implementation: `1.0 - n_pure(c)` — exact f64 arithmetic complement. /// This guarantees `drab(C) + n_pure(C) == 1.0` exactly in IEEE 754, which @@ -379,7 +395,7 @@ pub fn drab(c: f64) -> f64 { } // ───────────────────────────────────────────────────────────────────────────── -// Surround-aware оценка дефектов (Zone G) +// Context-parameterized legacy coordinates (Zone G) // // Тема снаружи (light / dark / light-ic / dark-ic) маппируется на ViewingConditions // по таблице CIECAM16 (Li et al. 2017, Table 1): @@ -390,21 +406,22 @@ pub fn drab(c: f64) -> f64 { // // Фон bg_hex даёт яркость Yb (Y-компонент фона в % от D65-белого), которая // подставляется в ViewingConditions::srgb_with_yb / dim_surround_with_yb. -// Это единственный канал влияния фона на дефект — через CAM16 J (apparent lightness) -// цвета под данным Yb+surround. +// Это единственный канал влияния фона на legacy coordinate — через CAM16 J +// цвета под данным Yb+surround. `bg_hex + theme` не описывают полный visual context: +// отсутствуют spatial distribution, surround variance, geometry, observer и adaptation history. // -// mud и drab считаются на (l_app=J/100, C_oklab, h_oklab): +// legacy `mud` и `drab` считаются на (l_app=J/100, C_oklab, h_oklab): // - C_oklab, h_oklab — Oklab-координаты исходного цвета (не меняются от surround) // - l_app = J/100 — CAM16 apparent lightness под фоном+темой, нормирован в [0,1] // // Это заменяет Oklab L в depth_term(l, h) (определяет положение цвета под cusp), -// делая оценку surround-aware: цвет на тёмном фоне «воспринимается светлее», -// лежит ближе к cusp-L и получает меньший depth_term → меньшую грязь. +// делая координату чувствительной к локальным Yb+theme inputs. Это не полная +// appearance model и не observer-validated оценка; полный context owner — Issue #230. // // Ноль новых параметров: все VC-параметры — таблица CIECAM16 (Li et al. 2017). // ───────────────────────────────────────────────────────────────────────────── -/// Тема просмотра — определяет условия окружения (surround) для оценки дефектов. +/// Compatibility theme input used to select the frozen CAM16 viewing conditions. /// /// Соответствие CIECAM16 (Li et al. 2017, Table 1): /// - `Light` → average surround (F=1.0, c=0.69, Nc=1.0) @@ -412,13 +429,13 @@ pub fn drab(c: f64) -> f64 { /// - `LightIc` / `DarkIc` — то же, но с флагом повышенного контраста #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Theme { - /// Светлая тема: average surround (CIECAM16 Table 1). + /// Light compatibility context: average surround (CIECAM16 Table 1). Light, - /// Тёмная тема: dim surround (CIECAM16 Table 1). + /// Dark compatibility context: dim surround (CIECAM16 Table 1). Dark, - /// Светлая тема с повышенным контрастом (IC). + /// Light compatibility context with the increased-contrast flag (IC). LightIc, - /// Тёмная тема с повышенным контрастом (IC). + /// Dark compatibility context with the increased-contrast flag (IC). DarkIc, } @@ -466,7 +483,8 @@ impl Theme { /// dark-темы — dim, IC-темы поднимают флаг повышенного контраста. Обе /// точки входа (дефолтный Yb на границе, Yb-от-фона в контексте дефектов) /// обязаны выбирать surround здесь — вторая копия карты в цветовом коде - /// расходилась бы тихо при добавлении темы. + /// расходилась бы тихо при добавлении темы. Эта карта не утверждает полноту + /// perceptual context; она только сохраняет legacy boundary contract. fn vc_by( self, srgb: impl FnOnce() -> crate::spaces::vc::ViewingConditions, @@ -483,9 +501,11 @@ impl Theme { } } -/// Контекст просмотра для surround-aware оценки дефектов. +/// Partial context input for the legacy experimental compatibility proxy. /// -/// Сочетает фон (hex-строка, задаёт яркость Yb) и тему (определяет surround). +/// Сочетает один фон (hex-строка, задаёт mean Yb) и тему (выбирает CAM16 surround). +/// Это не полный appearance context: variance, geometry, adaptation history and +/// observer state отсутствуют. Не использовать как human cleanliness verdict. /// Передаётся в `muddiness_in_context` / `drab_in_context`. #[derive(Debug, Clone, Copy)] pub struct DefectContext<'a> { @@ -521,7 +541,10 @@ fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingCondi ) } -/// Surround-aware оценка грязи цвета в заданном контексте просмотра. +/// Compute the legacy experimental compatibility proxy with local Yb+theme inputs. +/// +/// This is context-parameterized, not fully surround-aware: it does not model +/// spatial distribution, surround variance, geometry, adaptation history, or observers. /// /// # Алгоритм /// @@ -530,13 +553,13 @@ fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingCondi /// 3. Строим `ViewingConditions` для темы `ctx.theme` с данным Yb (CIECAM16 Table 1). /// 4. Из `hex` → XYZ → CAM16 `J` (apparent lightness под фоном+temой). /// 5. `l_app = J / 100` — нормированный apparent lightness ∈ [0, 1]. -/// 6. mud = `raw_chromatic(l_app, C_oklab, h_oklab)` — формула без изменений, +/// 6. legacy proxy = `raw_chromatic(l_app, C_oklab, h_oklab)` — формула без изменений, /// но `l_app` учитывает surround и фон вместо Oklab L. /// /// # Провенанс /// /// VC-параметры: CIECAM16, Li et al. 2017, DOI 10.1002/col.22131, Table 1. -/// Формула mud — без изменений (Zone B slices 3+4; H_Y_DEG, B0, BW цитированы). +/// Legacy formula is unchanged; provenance statuses are recorded in the empirical inventory. pub fn muddiness_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { // Oklab-координаты: C и h не зависят от viewing conditions let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; @@ -554,16 +577,14 @@ pub fn muddiness_in_context(hex: &str, ctx: DefectContext<'_>) -> Result) -> Result { - // drab зависит только от C_oklab — surround не меняет chroma в Oklab + // Historical formula depends only on C_oklab; context is not part of this coordinate. let _ = ctx; // принимается для симметрии API let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); @@ -575,182 +596,64 @@ pub fn drab_in_context(hex: &str, ctx: DefectContext<'_>) -> Result mod tests { use super::*; - // ─── Smoke / class-membership reference values ──────────────────────────── - // - // Обновлено в Zone B slice 4 (2026-06-30): W_HUE заменён выведенным Hanning-окном - // Бецольда-Брюкке. Диапазон mud значительно расширился: [0, ~0.41] вместо [0.00..0.14]. - // Это ожидаемо — BB-формула даёт hue_weight ≈ 1.0 в широком тёплом секторе вместо - // узкого sigmoid-пика из логистической регрессии W_HUE. - // - // Ранговый порядок грязности: olive > babypoop > gold2 > gold1 > puke - // (прим.: gold2 > gold1 — артефакт подогнанного W_HUE исчез; теперь глубина - // под cusp-L побеждает, что физически более верно). - // - // Класс теста: class A (unit + reference), Фаулер. - // TDD: RED до коммита 2 (W_HUE → BB), GREEN после. - #[test] - fn test_muddiness_v3_reference_values() { - // Olive #6B6B2E (Наиболее грязный — максимальный raw) - // Провенанс golden: mud = N(C) * hue_weight_BB(h) * depth_mod(L, C, h), - // hue_weight_BB(109°) = (1 + cos(109° - 90.4°)) / 2 ≈ 0.974. - let olive_mud = muddiness_from_hex("#6B6B2E").unwrap(); - assert!( - olive_mud > 0.30, - "olive mud is {olive_mud:.6}, expected > 0.30 (BB-диапазон)" - ); - assert!( - (olive_mud - 0.40640072).abs() < 1e-5, - "olive mud {olive_mud:.8} != 0.40640072 (Zone B slice 5 H_Y golden)" - ); - - // Babypoop #937C00 (Очень грязный) - // hue_weight_BB(96°) = (1 + cos(96° - 90.4°)) / 2 ≈ 0.998. - let babypoop_mud = muddiness_from_hex("#937C00").unwrap(); - assert!( - babypoop_mud > 0.20, - "babypoop mud is {babypoop_mud:.6}, expected > 0.20" - ); - assert!( - (babypoop_mud - 0.33220155).abs() < 1e-5, - "babypoop mud {babypoop_mud:.8} != 0.33220155" - ); - - // Puke #9AAE07 (Умеренно грязный) - let puke_mud = muddiness_from_hex("#9AAE07").unwrap(); - assert!( - (puke_mud - 0.23449461).abs() < 1e-5, - "puke mud {puke_mud:.8} != 0.23449461" - ); - - // Золотые (средний диапазон) - let gold1_mud = muddiness_from_hex("#9e6c00").unwrap(); - assert!( - (gold1_mud - 0.29296999).abs() < 1e-5, - "gold1 mud {gold1_mud:.8} != 0.29296999" - ); - - let gold2_mud = muddiness_from_hex("#8f6424").unwrap(); - assert!( - (gold2_mud - 0.30971375).abs() < 1e-5, - "gold2 mud {gold2_mud:.8} != 0.30971375" - ); - - // Ранговый порядок: olive > babypoop > gold2 > gold1 > puke >> achromatic - // Примечание: gold2 > gold1 (BB-формула; прежний порядок gold1 > gold2 был - // артефактом подогнанного W_HUE, не геометрическим свойством Oklab). - assert!( - olive_mud > babypoop_mud, - "olive должен быть грязнее babypoop: {olive_mud:.6} vs {babypoop_mud:.6}" - ); - assert!( - babypoop_mud > gold1_mud && babypoop_mud > gold2_mud, - "babypoop должен быть грязнее золотых: {babypoop_mud:.6} vs gold1={gold1_mud:.6} gold2={gold2_mud:.6}" - ); - assert!( - gold2_mud > gold1_mud, - "gold2 должен быть грязнее gold1 по BB-формуле: gold2={gold2_mud:.8} vs gold1={gold1_mud:.8}" - ); - assert!( - gold1_mud > puke_mud && gold2_mud > puke_mud, - "золотые должны быть грязнее puke: gold1={gold1_mud:.6} gold2={gold2_mud:.6} vs puke={puke_mud:.6}" - ); - - // Pure grey #808080 (Чистый — achromatic, N(C) → 0) - let grey_mud = muddiness_from_hex("#808080").unwrap(); - assert!( - grey_mud < 0.005, - "grey mud is {grey_mud:.6}, expected < 0.005 (N(C) подавляет)" - ); - - // Teal #008080 (прохладный — b < B0, depth_mod → почти 0) - let teal_mud = muddiness_from_hex("#008080").unwrap(); - assert!( - teal_mud < 0.01, - "teal mud is {teal_mud:.6}, expected < 0.01 (depth_mod b-гейт)" - ); - assert!( - (teal_mud - 0.00430967).abs() < 1e-5, - "teal mud {teal_mud:.8} != 0.00430967 (Zone B slice 5 H_Y golden)" - ); - - // Navy Blue #000080 (Чистый — синий, depth_mod → 0 через b-гейт) - let navy_mud = muddiness_from_hex("#000080").unwrap(); - assert!( - navy_mud < 0.001, - "navy mud is {navy_mud:.8}, expected < 0.001" - ); - } - // ─── Characterization golden test (Zone B slice 4, Fowler class B) ───────── // - // Фиксирует таблицу mud(L,C,h) после замены W_HUE на Hanning-окно BB. - // mud = N(C) * hue_weight_BB(h) * depth_mod(L, C, h) - // где hue_weight_BB(h) = (1 + cos(h − H_Y_DEG)) / 2 + // Фиксирует таблицу legacy coordinate после замены W_HUE на Hanning-эвристику. + // mud = N(C) * hue_weight(h) * depth_mod(L, C, h) + // где hue_weight(h) = (1 + cos(h - H_Y_DEG)) / 2 // // Golden-значения получены из фактического кода после коммита 2 (Zone B slice 4) // и зафиксированы как regression anchor. Tolerance: ±5e-6 (точность f64). // // Класс теста: B (characterization/golden, Фаулер) — якорь регрессии. // История: Zone B slice 3 (Platt-удаление) имела диапазон [0.00..0.14]; - // Zone B slice 4 (BB-замена) расширила до [0.00..0.41] — ожидаемо - // (hue_weight_BB ≈ 1.0 шире, чем узкий sigmoid-пик W_HUE). + // Zone B slice 4 (Hanning-замена) расширила до [0.00..0.41]. #[test] - fn characterization_mud_golden_cited() { + fn characterization_proxy_golden() { struct Case { hex: &'static str, - label: &'static str, expected: f64, } - // Значения = muddiness_oklch(l,c,h) (BB-путь, Zone B slice 4) + // Значения = muddiness_oklch(l,c,h) (frozen compatibility path). let cases = [ Case { hex: "#6B6B2E", - label: "olive", expected: 0.40640072, }, Case { hex: "#937C00", - label: "babypoop", expected: 0.33220155, }, Case { hex: "#9AAE07", - label: "puke", expected: 0.23449461, }, Case { hex: "#9e6c00", - label: "gold1", expected: 0.29296999, }, Case { hex: "#8f6424", - label: "gold2", expected: 0.30971375, }, Case { hex: "#808080", - label: "grey", expected: 0.00125988, }, Case { hex: "#008080", - label: "teal", expected: 0.00430967, }, Case { hex: "#000080", - label: "navy", expected: 0.00000000, }, Case { hex: "#FF0000", - label: "red", expected: 0.00133634, }, Case { hex: "#0000FF", - label: "blue", expected: 0.00000000, }, ]; @@ -758,8 +661,7 @@ mod tests { let got = muddiness_from_hex(c.hex).unwrap(); assert!( (got - c.expected).abs() < 5e-6, - "characterization_mud_golden_cited: {} {}: got={:.8} expected={:.8} delta={:.2e}", - c.label, + "characterization_proxy_golden: {}: got={:.8} expected={:.8} delta={:.2e}", c.hex, got, c.expected, @@ -912,71 +814,59 @@ mod tests { ); } - // ─── Zone B slice 4: W_HUE absent + Бецольд-Брюкке инварианты (TDD RED-first) ── + // ─── Zone B slice 4: W_HUE absent + Hanning invariants (TDD RED-first) ── // - // Три теста закрывают КЛАСС дефектов: «подогнанный вектор вместо выведенной формулы». + // Тесты закрывают класс дефектов: fitted vector вместо frozen parameter-free formula. // // (1) w_hue_fitted_vector_absent_from_shipping — RED: `pub static W_HUE` ещё // существует в продуктивном коде (и hue_basis, hue_weight ещё опираются на него). // GREEN после коммита 2: W_HUE убран, hue_weight переписан. // - // (2) bezold_brucke_at_unique_yellow_is_one — RED: текущий hue_weight(97°) ≈ 0.2 - // (sigmoid(dot(W_HUE, basis(97°))) ≠ 1.0). GREEN после коммита 2: - // выведенная формула (1 + cos(0)) / 2 == 1.0 точно. + // (2) hanning_at_reference_hue_is_one — formula equals 1 at its frozen centre. // - // (3) bezold_brucke_at_opposite_is_zero — RED: текущий hue_weight(277°) ≈ 0.015 - // (sigmoid(dot) ≠ 0.0). GREEN после коммита 2: - // (1 + cos(π)) / 2 == 0.0 точно. + // (3) hanning_at_opposite_hue_is_zero — formula equals 0 at the opposite hue. // - // (4) bezold_brucke_monotone_from_unique_yellow — RED: текущая формула не является - // Hanning-окном и нарушает монотонное убывание от h_Y по обе стороны. - // GREEN после коммита 2: cos(δ) монотонно убывает при |δ| растёт от 0 до π. + // (4) hanning_monotone_from_reference_hue — cosine decreases as |delta| grows. // - // Класс багов: fitted-вектор не имеет гарантированных инвариантов hue_weight(h_Y) = 1, - // hue_weight(h_Y + 180°) = 0 и монотонности; выведенная формула доказывает их аналитически. + // The invariants are mathematical only; they do not validate observer semantics. // - // Провенанс: - // H_Y_DEG = 90.4011° — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° наблюдатель, - // D65). Derivation (slice 5): CMF при 578nm — линейная интерполяция CIE 5нм-таблицы + // Arithmetic provenance: + // H_Y_DEG = 90.4011° — Oklab hue of the selected λ=578nm reference using + // CIE 1931 2° CMFs. Derivation (slice 5): linear interpolation of the 5nm table // (575nm: 0.8425/0.9154/0.0018; 580nm: 0.9163/0.8700/0.00165) → (0.886780, 0.888160, // 0.001710), → XYZ/Y = (0.998446, 1, 0.001925), → Oklab напрямую (Ottosson 2020, M1/M2; // hue инвариантен к радиансности, sRGB-клэмп не нужен и не hue-сохраняющ), // → atan2(b, a) = 90.4011°. Неопределённость 5нм-интерполяции: ±0.01° // (1нм-таблица CIE даёт 90.3925° — расхождение 0.009°, на 4 порядка ниже // ширины Hanning-окна; центр объявлен по официальной 5нм-таблице). - // Формула (1 + cos(h − h_Y)) / 2 — Hanning-окно, выведенное из производной - // поворота Бецольда-Брюкке: dΔH_BB/dh = A_BB · cos(h − h_Y), нормированная - // в [0, 1]. Цитаты: Purdy (1937) Am. J. Psychol. 49, 313–315 (инвариант 578nm); - // Jacobs & Wascher (1967) J. Opt. Soc. Am. 57, 1155–1156. + // This arithmetic does not establish a universal unique-yellow observer point. + // The Hanning formula is a heuristic and is not derived from Bezold-Brucke. #[test] fn w_hue_fitted_vector_absent_from_shipping() { // RED пока pub static W_HUE присутствует в продуктивном коде. - // GREEN после замены на выведенную BB-формулу. + // GREEN после замены на frozen Hanning formula. let source = include_str!("cleanliness.rs"); let prod_code = source.split("#[cfg(test)]").next().unwrap_or(source); assert!( !prod_code.contains("pub static W_HUE"), "pub static W_HUE найден в продуктивном коде — подогнанный вектор (M-12) не удалён. \ - Ожидается выведенная формула Бецольда-Брюкке." + Ожидается frozen Hanning formula." ); // Дополнительно: hue_basis (вспомогательная функция для dot-product) тоже должна уйти assert!( !prod_code.contains("fn hue_basis("), "fn hue_basis( найдена в продуктивном коде — K=3 Фурье-базис больше не нужен \ - после перехода на BB-формулу." + после перехода на Hanning formula." ); } #[test] - fn bezold_brucke_at_unique_yellow_is_one() { + fn hanning_at_reference_hue_is_one() { // Hanning-окно: (1 + cos(h − h_Y)) / 2 == 1.0 точно при h == h_Y. - // RED: текущий sigmoid(dot(W_HUE, basis(H_Y_DEG))) ≠ 1.0. - // GREEN: выведенная BB-формула гарантирует (1 + cos(0)) / 2 == 1.0. + // The frozen formula guarantees (1 + cos(0)) / 2 == 1.0. // - // Значение H_Y_DEG определяется из CIE 1931 2° (D65) → derivation в - // комментарии к тест-блоку выше. Мы тестируем через публичный `hue_weight`: - // если формула выведена верно, hue_weight(H_Y_DEG) == 1.0 ТОЧНО (не ≈). + // H_Y_DEG is the selected arithmetic reference documented above. let hw = hue_weight(H_Y_DEG); assert_eq!( hw, 1.0, @@ -986,10 +876,9 @@ mod tests { } #[test] - fn bezold_brucke_at_opposite_is_zero() { + fn hanning_at_opposite_hue_is_zero() { // Hanning-окно: (1 + cos(π)) / 2 == 0.0 ТОЧНО при h == h_Y + 180°. - // RED: текущий sigmoid(dot) ≈ 0.015, не равен 0.0. - // GREEN: выведенная формула гарантирует нулевой вес на противоположном оттенке. + // The frozen formula guarantees zero at the opposite hue. let hw = hue_weight(H_Y_DEG + 180.0); assert_eq!( hw, @@ -1001,10 +890,9 @@ mod tests { } #[test] - fn bezold_brucke_monotone_from_unique_yellow() { + fn hanning_monotone_from_reference_hue() { // Hanning-окно строго монотонно убывает при |δ| растёт от 0 до π. - // RED: текущая sigmoid(dot(W_HUE,...)) — не монотонное Hanning-окно. - // GREEN: (1 + cos(δ)) / 2 монотонно убывает по обе стороны от h_Y. + // (1 + cos(delta)) / 2 decreases on both sides of its reference hue. // // Тест: для 360 равномерных шагов δ от 0 до 180° включительно, // hue_weight(h_Y + δ) должен строго убывать. @@ -1038,69 +926,34 @@ mod tests { } } - // ─── Provenance-range guard (Zone B, Fowler class A — property test) ───── - // - // Asserts B0 lies in [0.030, 0.044] and BW lies in [0.013, 0.020] — the - // cited-measured ranges (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 - // PNAS; Boynton 1975). This test was RED against the pre-swap fitted - // values (B0=0.02869 < 0.030, BW=0.02024 > 0.020) and turned GREEN after - // the swap — proving it bites (TDD RED-first; verified in git history as - // the compile-failing state at commit 1 HEAD). - // - // Uses `const { assert!(..) }` so the range check is enforced at COMPILE - // TIME (clippy::assertions_on_constants, Rust ≥1.96). A future constant - // edit outside the cited range will be caught at `cargo build`, not just - // at test time. - #[test] - fn provenance_range_guard_b0_bw() { - const { - assert!( - B0 >= 0.030 && B0 <= 0.044, - "B0 is outside the cited range [0.030, 0.044] \ - (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). \ - M-04 must be cited-measured." - ) - }; - const { - assert!( - BW >= 0.013 && BW <= 0.020, - "BW is outside the cited range [0.013, 0.020] \ - (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). \ - M-05 must be cited-measured." - ) - }; - } - - // ─── Zone G: surround-aware кейс-тесты (Fowler class A, TDD RED-first) ──── + // ─── Zone G: local-context sensitivity tests (Fowler class A) ───────────── // - // Условие успеха (Зона G exit-criteria): - // (1) Серый (#808080) на пастельном фоне (#FFE4E1) даёт более высокую грязь, - // чем тот же серый на нейтральном (#808080) — разница строго по знаку. - // (2) Тёмный розовый (#C2185B) на чёрном фоне (#000000) даёт более низкую грязь, - // чем тот же цвет на белом фоне (#FFFFFF) — направление дельты строго. + // Characterization contract: changing the supplied Yb+theme inputs changes + // CAM16 J and therefore the frozen proxy in the recorded directions. + // This proves wiring and regression sensitivity, not human cleanliness truth + // or completeness of the visual context model. // // Почему тест кусается (mutation-bite): - // Если заменить l_app = J/100 на l_app = Oklab-L (т.е. убрать surround-aware путь), + // Если заменить l_app = J/100 на l_app = Oklab-L (убрать local-context path), // оба теста провалятся: без учёта Yb фона CAM16 J не меняется при смене bg_hex, // поэтому muddiness_in_context вернёт одинаковое значение для обоих фонов. // // TDD RED-first: до добавления `muddiness_in_context` в файл эти тесты не // компилировались (функция не существовала) — RED доказан структурно. // - // Провенанс: параметры CAM16 — Li et al. 2017 Table 1 (cited); mud-формула — - // Zone B slices 3+4 (BB Hanning, B0/BW, C0/JND — все cited). + // CAM16 viewing-condition parameters: Li et al. 2017 Table 1. Legacy proxy + // parameter statuses are separate and recorded in the empirical inventory. use super::{DefectContext, Theme, drab_in_context, muddiness_in_context}; - /// Серый на пастельном фоне грязнее, чем тот же серый на нейтральном. + /// The frozen proxy is higher for #808080 with the supplied pastel background. /// - /// Физика: пастельный фон (#FFE4E1, Yb≈82%) повышает адаптацию → сдвигает - /// CAM16 J серого (#808080, Yb≈22%) вниз от cusp-L → depth_term растёт → - /// mud растёт. На нейтральном фоне (#808080, Yb≈22%) адаптация стандартная. + /// In this partial model, #FFE4E1 supplies a higher Yb than #808080, changing + /// CAM16 J and the geometric depth input. No observer judgement is asserted. /// /// Направление дельты: mud_on_pastel > mud_on_neutral — строго. #[test] - fn grey_on_pastel_is_muddier_than_grey_on_neutral() { + fn proxy_for_grey_is_higher_with_pastel_yb_than_neutral_yb() { let grey = "#808080"; let pastel_bg = "#FFE4E1"; // розово-белёсый, Yb≈82% let neutral_bg = "#808080"; // нейтральный серый, Yb≈22% @@ -1125,22 +978,19 @@ mod tests { assert!( mud_on_pastel > mud_on_neutral, - "Серый на пастельном фоне должен быть грязнее, чем на нейтральном: \ - mud_on_pastel={mud_on_pastel:.6} должно быть > mud_on_neutral={mud_on_neutral:.6}. \ - Если равны — surround-aware путь не работает (J не учитывает Yb фона)." + "local-context proxy ordering changed: pastel={mud_on_pastel:.6} \ + must be > neutral={mud_on_neutral:.6}; equality means Yb no longer reaches CAM16 J" ); } - /// Тёмный розовый чист (низкая грязь) на чёрном и грязнее на белом. + /// The frozen proxy is lower for #C2185B with black than with white inputs. /// - /// Физика: на белом фоне (#FFFFFF, Yb≈100%) CAM16 J тёмного розового (#C2185B) - /// ниже, чем на чёрном (#000000, Yb≈0.5%), — он выглядит тёмным относительно - /// светлого окружения, глубже под cusp-L → depth_term растёт → mud растёт. - /// На чёрном фоне адаптация к темноте, J цвета выше → depth_term меньше → mud меньше. + /// In this partial model the two Yb+theme inputs produce different CAM16 J + /// and geometric depth values. No human clean/dirty direction is asserted. /// /// Направление дельты: mud_on_black < mud_on_white — строго. #[test] - fn dark_pink_is_cleaner_on_black_than_on_white() { + fn proxy_for_dark_pink_is_lower_with_black_input_than_white_input() { let dark_pink = "#C2185B"; // тёмно-розовый (Material Design Pink 800) let black_bg = "#000000"; let white_bg = "#FFFFFF"; @@ -1165,19 +1015,18 @@ mod tests { assert!( mud_on_black < mud_on_white, - "Тёмный розовый должен быть чище (меньше грязи) на чёрном фоне, чем на белом: \ - mud_on_black={mud_on_black:.6} должно быть < mud_on_white={mud_on_white:.6}. \ - Если равны — surround-aware путь не работает." + "local-context proxy ordering changed: black={mud_on_black:.6} \ + must be < white={mud_on_white:.6}; equality means context no longer reaches the formula" ); } - /// Mutation-bite: подмена l_app на фиксированное значение (убираем surround-aware путь) + /// Mutation-bite: replacing l_app with a fixed value removes local-context sensitivity /// должна РОНЯТЬ первый кейс-тест. Проверяем здесь что тест различает два значения. /// /// Реализация: вычисляем mud дважды — с пастельным и нейтральным фоном. /// Разница строго ненулевая → epsilon-тест подтверждает укус. #[test] - fn surround_aware_mud_differs_across_backgrounds_epsilon() { + fn compatibility_proxy_differs_across_local_background_inputs() { let grey = "#808080"; let pastel_bg = "#FFE4E1"; let neutral_bg = "#808080"; @@ -1202,19 +1051,20 @@ mod tests { // Разница должна быть не менее 1e-4 — иначе тест не кусается assert!( (mud_pastel - mud_neutral).abs() > 1e-4, - "surround-aware mud разница < 1e-4: pastel={mud_pastel:.8} neutral={mud_neutral:.8} \ + "local-context proxy delta < 1e-4: pastel={mud_pastel:.8} neutral={mud_neutral:.8} \ delta={:.2e} — тест не кусается (mutation-bite провален).", (mud_pastel - mud_neutral).abs() ); } - /// API-test: drab_in_context принимает контекст и возвращает то же значение, что drab(C). + /// API-test: `drab_in_context` preserves the context-independent legacy coordinate. /// - /// Тест закрывает класс: drab не должен молча игнорировать контекст и возвращать ноль. + /// The boundary accepts context for compatibility, ignores it explicitly, and must not + /// fabricate zero instead of returning the historical arithmetic result. #[test] fn drab_in_context_matches_bare_drab() { use super::n_pure; - let hex = "#937C00"; // babypoop — умеренная хрома + let hex = "#937C00"; // frozen conformance fixture let ctx_light = DefectContext { bg_hex: "#FFFFFF", @@ -1243,21 +1093,20 @@ mod tests { assert_eq!( d_light + n_pure(c_oklab), 1.0, - "drab_in_context(babypoop) + n_pure должно быть ровно 1.0 (D+N=1 инвариант)" + "drab_in_context({hex}) + n_pure должно быть ровно 1.0 (D+N=1 инвариант)" ); - // babypoop (#937C00) — сильно хроматический цвет: C >> C0=0.0395, - // поэтому drab(C) = 1 - N_pure(C) ≈ 0 (т.е. < 0.1 — почти нет тусклости). + // For this fixture C >> C0, so the frozen arithmetic complement is < 0.1. assert!( d_light < 0.1, - "drab_in_context(babypoop) = {d_light:.6} ожидается < 0.1 \ - (babypoop сильно хроматичен, drab → 0)" + "drab_in_context({hex}) = {d_light:.6} ожидается < 0.1 \ + (fixture C >> C0, so the compatibility coordinate approaches 0)" ); } /// IC-тема компилируется и возвращает корректный результат. #[test] fn ic_themes_compile_and_return_finite_value() { - let hex = "#6B6B2E"; // olive + let hex = "#6B6B2E"; // frozen conformance fixture let ctx_lic = DefectContext { bg_hex: "#FFFFFF", theme: Theme::LightIc, diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 45ad6ddb..4a6a705f 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -192,8 +192,9 @@ pub enum ConfigError { }, /// Ссылка (алиас/alpha_analog) на роль, которой нет в `roles`. UnknownRole { referenced_by: String, role: String }, - /// Дубликат ключа в словаре конфига: повтор имени сделал бы lookup и - /// эмиссию неоднозначными (какая запись выиграла — вопрос порядка, тихо). + /// Дубликат ключа в словаре конфига или итоговом CSS-namespace: повтор имени + /// сделал бы lookup/эмиссию неоднозначными (какая запись выиграла — вопрос + /// порядка, тихо). DuplicateKey { dictionary: &'static str, key: String, @@ -529,6 +530,8 @@ pub enum RoleRecipe { source: LadderSource, /// Контрактная ступень стека: subtle | base | bloom. step: crate::glow::GlowStep, + /// Обязательный numerical-decision profile; implicit legacy запрещён. + decision_profile: crate::glow::GlowDecisionProfileV1, }, /// Заливка пары «поверхность × лейбл» ([`crate::pair`]): якорь источника, /// минимально сдвинутый по светлоте до победы перцептивно правильной @@ -598,6 +601,56 @@ pub enum RoleRecipe { Zero, } +/// Суффиксы CSS-переменных, которые один объявленный рецепт резервирует. +/// +/// Основное имя принадлежит клиентскому токену даже тогда, когда `Zero` не +/// эмитит значения: projection всё равно публикует его `cssVar`, и сателлит +/// другой роли не вправе занять это имя. Остальной shape выводится из рецепта +/// до резолва, иначе коллизия могла бы зависеть от фона (на одном роль +/// unreachable и «всё работает», на другом два писателя молча делят один +/// `--lab-*`). Исчерпывающий match заставляет каждый новый рецепт явно выбрать +/// свой namespace shape. +fn reserved_css_suffixes(recipe: &RoleRecipe) -> &'static [&'static str] { + const PRIMARY: &[&str] = &[""]; + const GLOW: &[&str] = &["", "-core", "-alpha"]; + const MATERIAL: &[&str] = &["", "-01", "-02"]; + + match recipe { + RoleRecipe::Glow { .. } => GLOW, + RoleRecipe::Material { .. } => MATERIAL, + RoleRecipe::TextAnchor { .. } + | RoleRecipe::DjAnchor { .. } + | RoleRecipe::DecorativeLc { .. } + | RoleRecipe::Ladder { .. } + | RoleRecipe::PairFill { .. } + | RoleRecipe::PairLabel { .. } + | RoleRecipe::AlphaAnalog { .. } + | RoleRecipe::Zero => PRIMARY, + } +} + +/// Зарезервировать один shape эмиссии в общем namespace. +/// +/// Отдельный примитив не знает сегодняшних суффиксов и потому не опирается на +/// случайное свойство, что `-core/-alpha/-01/-02` пока не пересекаются друг с +/// другом: будущая derived↔derived коллизия попадёт в тот же гард. +fn reserve_css_names( + reserved: &mut std::collections::BTreeSet, + name: &str, + suffixes: &[&str], +) -> Result<(), ConfigError> { + for suffix in suffixes { + let key = format!("--lab-{name}{suffix}"); + if !reserved.insert(key.clone()) { + return Err(ConfigError::DuplicateKey { + dictionary: "reserved CSS namespace", + key, + }); + } + } + Ok(()) +} + /// Источник тинта лестницы/альфа-аналога: откуда берётся якорный цвет. /// /// Тинт bg-независим (это якорь источника), только пер-темен. Для [`Family`](Self::Family) @@ -923,15 +976,14 @@ impl ThemeConfig { family: cat.family.clone(), }); } - if let Some(side) = cat.preferred_side - && side != 1 - && side != -1 - { - return Err(ConfigError::OutOfBounds { - handle: format!("sentiments.{}.preferred_side", cat.name), - value: f64::from(side), - bound: "preferred_side ∈ {-1, +1} (закрытое меню сторон смещения)", - }); + if let Some(side) = cat.preferred_side { + if side != 1 && side != -1 { + return Err(ConfigError::OutOfBounds { + handle: format!("sentiments.{}.preferred_side", cat.name), + value: f64::from(side), + bound: "preferred_side ∈ {-1, +1} (закрытое меню сторон смещения)", + }); + } } if let Some(hue) = cat.hue_floor_deg { let field = format!("sentiments.{}.hue_floor_deg", cat.name); @@ -946,15 +998,16 @@ impl ThemeConfig { } } - if let Some(hue) = self.neutral.tint.hue_override_deg - && !(hue.is_finite() + if let Some(hue) = self.neutral.tint.hue_override_deg { + if !(hue.is_finite() && (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&hue)) - { - return Err(ConfigError::OutOfBounds { - handle: "neutral.tint.hue_override_deg".to_string(), - value: hue, - bound: "0 ≤ hue < 360 (явный оттенок подтона по модулю 360°)", - }); + { + return Err(ConfigError::OutOfBounds { + handle: "neutral.tint.hue_override_deg".to_string(), + value: hue, + bound: "0 ≤ hue < 360 (явный оттенок подтона по модулю 360°)", + }); + } } // Дубликаты ключей всех словарей: повтор имени = неоднозначный lookup. @@ -1019,6 +1072,30 @@ impl ThemeConfig { } } + // Роль резервирует не только собственный `--lab-{name}`: Glow и Material + // создают сателлиты. Алиас клонирует outcome цели и потому создаёт тот же + // набор уже под СВОИМ именем. Проверяем итоговый namespace целиком до + // резолва/JSON, чтобы порядок писателей никогда не решал, чьё значение + // молча победит. Один общий set ловит role↔satellite, alias↔satellite и + // любые будущие satellite↔satellite пересечения без списка частных пар. + let mut reserved = std::collections::BTreeSet::new(); + + for (name, recipe) in &self.roles { + reserve_css_names(&mut reserved, name, reserved_css_suffixes(recipe))?; + } + for (alias, target) in &self.aliases { + let target_recipe = self + .roles + .iter() + .find(|(name, _)| name == target) + .map(|(_, recipe)| recipe) + .ok_or_else(|| ConfigError::UnknownRole { + referenced_by: format!("aliases.{alias}"), + role: target.clone(), + })?; + reserve_css_names(&mut reserved, alias, reserved_css_suffixes(target_recipe))?; + } + Ok(()) } @@ -1243,9 +1320,14 @@ impl ThemeConfig { magnitude: *magnitude, }), RoleRecipe::Zero => Ok(RoleSpec::Zero), - RoleRecipe::Glow { source, step } => Ok(RoleSpec::Glow { + RoleRecipe::Glow { + source, + step, + decision_profile, + } => Ok(RoleSpec::Glow { tint: self.compile_ladder_tint(role, source)?, step: *step, + decision_profile: *decision_profile, }), RoleRecipe::Ladder { source, @@ -1603,5 +1685,9 @@ pub(crate) mod preset; #[cfg(test)] pub(crate) mod fixture; +/// Общая строковая форма `Resolved` для in-crate characterization/golden-тестов. +#[cfg(test)] +pub(crate) mod test_support; + #[cfg(test)] mod tests; diff --git a/crates/labcolors-core/src/config/preset.rs b/crates/labcolors-core/src/config/preset.rs index ab20e59e..e523004c 100644 --- a/crates/labcolors-core/src/config/preset.rs +++ b/crates/labcolors-core/src/config/preset.rs @@ -251,9 +251,17 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // (зеркало fx-shadow-ambient) на фактическом фоне. Прежние Ladder@52 // (фикс-альфа, нормальная композиция) вырождались на одноимённых фонах; // physics свечения — добавление света, не наложение краски. + // Этот characterization-пресет явно выбирает legacy-профиль только ради + // совместимости с frozen byte-контрактом прежней glow-эмиссии; сам legacy- + // профиль не превращается от этого в cross-runtime гарантию. `StableV1` без + // sound CAM16 bound честно вернул бы `GlowIndeterminate` для каждого + // нетривиального свечения и тем самым намеренно изменил бы frozen output; + // implicit fallback здесь отсутствует, а новые клиентские схемы обязаны + // выбрать профиль сами. let glow = |source: LadderSource| RoleRecipe::Glow { source, step: crate::glow::GlowStep::Base, + decision_profile: crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1, }; roles.push(("fx-glow-brand".to_string(), glow(LadderSource::Brand))); roles.push(( diff --git a/crates/labcolors-core/src/config/test_support.rs b/crates/labcolors-core/src/config/test_support.rs new file mode 100644 index 00000000..2b9b1237 --- /dev/null +++ b/crates/labcolors-core/src/config/test_support.rs @@ -0,0 +1,19 @@ +//! Общие helpers тестовой конфигурационной поверхности. +//! +//! Модуль существует только под `cfg(test)`: строковая форма ниже — оракул +//! characterization/golden-тестов, а не второй публичный формат эмиссии. + +use crate::Resolved; + +/// Каноническая строковая форма решённой роли для in-crate golden-гейтов. +pub(crate) fn resolved_repr(res: &Resolved) -> String { + match res { + Resolved::Color { solved, .. } => solved.hex().to_string(), + Resolved::Translucent(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), + Resolved::Glow(g) => format!("glow({},{},{:.4})", g.core_hex(), g.halo_hex(), g.alpha()), + Resolved::GlowIndeterminate(_) => "GLOW_INDETERMINATE".to_string(), + Resolved::Material(m) => format!("material({},{:.4})", m.tint_hex(), m.alpha()), + Resolved::None => "none".to_string(), + Resolved::Unreachable(_) => "UNREACHABLE".to_string(), + } +} diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index fa890505..4e5ffb9d 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -9,6 +9,7 @@ //! сверка со стабом labui (light+dark) + RED-proof мутаций. use super::fixture::labui_reference; +use super::test_support::resolved_repr as repr; use super::*; use crate::ladder::LadderPosition; use crate::solve::Floor; @@ -30,21 +31,6 @@ fn grid() -> ([(ViewingConditions, &'static str); 2], [&'static str; 6]) { ) } -/// Hex/none/UNREACHABLE-представление резолва — как в golden-тесте ядра. -fn repr(res: &Resolved) -> String { - match res { - Resolved::Color { solved, .. } => solved.hex().to_string(), - // полупрозрачная роль: тинт + фактическая альфа — то, что эмитится `--lab-*`. - Resolved::Translucent(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), - // Свечение: слои + α — стабильное представление для голденов. - Resolved::Glow(g) => format!("glow({},{},{:.4})", g.core_hex(), g.halo_hex(), g.alpha()), - // Материал: тон + выведенная α — стабильное представление. - Resolved::Material(m) => format!("material({},{:.4})", m.tint_hex(), m.alpha()), - Resolved::None => "none".to_string(), - Resolved::Unreachable(_) => "UNREACHABLE".to_string(), - } -} - /// Собрать карту `role.key() -> hex` из дефолтной таблицы для (bg, vc). fn default_by_key(bg: &BgInput, vc: &ViewingConditions) -> Vec<(&'static str, String)> { resolve_set(bg, &RoleTable::default(), vc) @@ -1577,6 +1563,206 @@ fn validator_rejects_duplicate_dictionary_keys() { )); } +/// Имена конфига — не весь namespace эмиссии: Glow добавляет `-core/-alpha`, +/// Material — `-01/-02`. Роль или алиас с таким именем раньше проходили +/// preflight, а JSON-проекция молча записывала один `--lab-*` дважды; последний +/// писатель менял тип значения (например, alpha-число превращалось в цвет). +#[test] +fn validator_rejects_role_and_alias_collisions_with_emitted_satellites() { + let assert_collision = |cfg: ThemeConfig, expected_stem: &str| { + let expected = format!("--lab-{expected_stem}"); + assert!( + matches!( + cfg.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "reserved CSS namespace", + key, + }) if key == expected + ), + "коллизия эмитируемого ключа {expected} обязана быть отвергнута" + ); + }; + + // Реальный класс регрессии: существующий glow владеет двумя сателлитами. + for suffix in ["-core", "-alpha"] { + let colliding = format!("fx-glow-brand{suffix}"); + + let mut role_cfg = labui_reference(); + let ordinary_recipe = role_cfg + .roles + .iter() + .find(|(name, _)| name == "label-primary") + .expect("фикстура несёт label-primary") + .1 + .clone(); + role_cfg.roles.push((colliding.clone(), ordinary_recipe)); + assert_collision(role_cfg, &colliding); + + let mut alias_cfg = labui_reference(); + alias_cfg + .aliases + .push((colliding.clone(), "label-primary".to_string())); + assert_collision(alias_cfg, &colliding); + } + + // Тот же закон обязан закрывать второй многоключевой outcome, а не только + // конкретный найденный суффикс glow. + for suffix in ["-01", "-02"] { + let colliding = format!("probe-material{suffix}"); + + let mut role_cfg = labui_reference(); + role_cfg.roles.push(( + "probe-material".to_string(), + neutral_material(10.0, Floor::AaText), + )); + let ordinary_recipe = role_cfg + .roles + .iter() + .find(|(name, _)| name == "label-primary") + .expect("фикстура несёт label-primary") + .1 + .clone(); + role_cfg.roles.push((colliding.clone(), ordinary_recipe)); + assert_collision(role_cfg, &colliding); + + let mut alias_cfg = labui_reference(); + alias_cfg.roles.push(( + "probe-material".to_string(), + neutral_material(10.0, Floor::AaText), + )); + alias_cfg + .aliases + .push((colliding.clone(), "label-primary".to_string())); + assert_collision(alias_cfg, &colliding); + } + + // Алиас многоключевой цели сам становится владельцем полного shape. Это + // отдельная ветвь: проверка только рецептов ролей пропустила бы её. + for suffix in ["-core", "-alpha"] { + let owner = "probe-glow-alias"; + let colliding = format!("{owner}{suffix}"); + let mut cfg = labui_reference(); + cfg.aliases + .push((owner.to_string(), "fx-glow-brand".to_string())); + let ordinary_recipe = cfg + .roles + .iter() + .find(|(name, _)| name == "label-primary") + .expect("фикстура несёт label-primary") + .1 + .clone(); + cfg.roles.push((colliding.clone(), ordinary_recipe)); + assert_collision(cfg, &colliding); + } + + for suffix in ["-01", "-02"] { + let owner = "probe-material-alias"; + let colliding = format!("{owner}{suffix}"); + let mut cfg = labui_reference(); + cfg.roles.push(( + "probe-material".to_string(), + neutral_material(10.0, Floor::AaText), + )); + cfg.aliases + .push((owner.to_string(), "probe-material".to_string())); + cfg.aliases + .push((colliding.clone(), "label-primary".to_string())); + assert_collision(cfg, &colliding); + } +} + +/// `Zero` не эмитит значение, но его клиентское имя всё равно занято: иначе +/// сателлит другой роли мог бы записать цвет в `cssVar` токена с `kind: "none"`. +/// Закон одинаков для явной zero-роли и алиаса на неё, а также для каждого +/// многоключевого shape, известного core. +#[test] +fn validator_reserves_zero_role_and_alias_primary_names() { + let assert_collision = |cfg: ThemeConfig, expected_stem: &str| { + let expected = format!("--lab-{expected_stem}"); + assert!( + matches!( + cfg.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "reserved CSS namespace", + key, + }) if key == expected + ), + "zero-токен обязан защищать зарезервированный CSS key {expected}" + ); + }; + + let glow_recipe = labui_reference() + .roles + .into_iter() + .find(|(name, _)| name == "fx-glow-brand") + .expect("фикстура несёт fx-glow-brand") + .1; + + for (owner, recipe, suffixes) in [ + ("probe-glow", glow_recipe, &["-core", "-alpha"][..]), + ( + "probe-material", + neutral_material(10.0, Floor::AaText), + &["-01", "-02"][..], + ), + ] { + for suffix in suffixes { + let zero_name = format!("{owner}{suffix}"); + + let mut role_cfg = labui_reference(); + role_cfg.roles.push((owner.to_string(), recipe.clone())); + role_cfg.roles.push((zero_name.clone(), RoleRecipe::Zero)); + assert_collision(role_cfg, &zero_name); + + let mut alias_cfg = labui_reference(); + alias_cfg.roles.push((owner.to_string(), recipe.clone())); + alias_cfg + .aliases + .push((zero_name.clone(), "none".to_string())); + assert_collision(alias_cfg, &zero_name); + } + } +} + +/// Сегодняшние суффиксы не пересекаются друг с другом, но сам примитив +/// namespace не должен полагаться на это случайное свойство. Синтетические +/// shapes доказывают derived↔derived ветвь: два разных владельца строят один +/// итоговый ключ, и второй резерв немедленно падает. +#[test] +fn emitted_namespace_primitive_rejects_satellite_to_satellite_collision() { + let mut reserved = std::collections::BTreeSet::new(); + reserve_css_names(&mut reserved, "probe", &["-outer-inner"]).expect("первый сателлит свободен"); + let error = reserve_css_names(&mut reserved, "probe-outer", &["-inner"]) + .expect_err("derived↔derived коллизия обязана быть отвергнута"); + + assert!(matches!( + error, + ConfigError::DuplicateKey { + dictionary: "reserved CSS namespace", + key, + } if key == "--lab-probe-outer-inner" + )); +} + +/// Гард не должен превращаться в запрет похожих префиксов: резервируются ровно +/// фактически эмитируемые имена, а не все строки, начинающиеся с имени роли. +#[test] +fn emitted_namespace_allows_non_colliding_near_misses() { + let mut cfg = labui_reference(); + cfg.aliases.push(( + "fx-glow-brand-alpha-extra".to_string(), + "label-primary".to_string(), + )); + cfg.roles.push(( + "probe-material".to_string(), + neutral_material(10.0, Floor::AaText), + )); + cfg.aliases + .push(("probe-material-03".to_string(), "label-primary".to_string())); + + assert_eq!(cfg.validate(), Ok(())); +} + /// preferred_side — закрытое меню {-1, +1}: 0 и 2 отвергаются. #[test] fn validator_rejects_preferred_side_outside_closed_menu() { @@ -1965,7 +2151,8 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() { .unwrap_or_else(|| panic!("{name} должен быть Translucent")); let tint = srgb_encoded_from_hex(t.tint_hex()).unwrap(); let prev_hex = hex_from_srgb_encoded(state); - state = composite_over_encoded(tint, t.alpha(), state); + state = composite_over_encoded(tint, t.alpha(), state) + .expect("эмитированные tint/alpha и предыдущий композит лежат в домене"); let state_hex = hex_from_srgb_encoded(state); // (1) слой меняет пиксели поверх уже наслоённого стека. assert_ne!( @@ -1989,8 +2176,8 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() { /// Ladder@52-строк): (а) на тёмной базе паспорта свечение решается БЕЗ /// деградации, halo = пер-темный якорь источника, α ∈ (0, 1], фактический шаг /// в допуске квантования от контрактной ступени Base; (б) на белом фоне -/// белое нейтральное свечение деградирует ЧЕСТНО (screen гаснет физически) — -/// флаг degraded, не молчание и не ошибка (ADR-0002, закон 2). +/// белое нейтральное свечение деградирует ЧЕСТНО (на белом screen — point-no-op +/// reference-профиля) — флаг degraded, не молчание и не ошибка. #[test] fn glow_roles_resolve_screen_layers() { let cfg = labui_reference(); @@ -2233,8 +2420,9 @@ fn material_two_layer_solid_canon_byte_exact_and_guaranteed() { assert!((m.floor() - 4.5).abs() < 1e-12, "AA-text пол = 4.5"); } -/// Гарантия читаемости пересчитываема потребителем из эмитированных `01`/`02`: -/// композит квантованного тинта над чёрным/белым точно даёт худший контраст. +/// Гарантия читаемости пересчитываема из эмитированных `01`/`02`: public core +/// recheck побитно совпадает с сохранённым conservative verdict, а независимые +/// byte-scale consumer probes не опускаются ниже него. #[test] fn material_guarantee_recomputable_over_worst_backdrop() { let res = material_on_white(15.0); @@ -2242,27 +2430,47 @@ fn material_guarantee_recomputable_over_worst_backdrop() { panic!("ожидался Material"); }; let tint = crate::spaces::srgb::srgb_encoded_from_hex(m.tint_hex()).unwrap(); - // ЭКСАКТНЫЙ композит квантованного тинта (как ядро и потребитель), без - // переквантования — над двумя углами полного коридора. - let over_black = crate::alpha::composite_over_encoded(tint, m.alpha(), [0.0; 3]); - let over_white = crate::alpha::composite_over_encoded(tint, m.alpha(), [1.0; 3]); + let recomputed = crate::material::worst_contrast_encoded( + tint, + m.alpha(), + &crate::material::BackdropBox::FULL, + m.pole(), + ) + .unwrap(); + assert_eq!(recomputed.to_bits(), m.worst_contrast().to_bits()); + + // Independent official scalar order. The old version of this test called + // alpha::composite_over_encoded, which is the normalized-expanded profile + // and therefore could not prove material consumer parity. let pole_lum = if matches!(m.pole(), crate::material::Pole::White) { 1.0 } else { 0.0 }; - let recomputed = - crate::wcag::ratio_from_luminances(pole_lum, crate::wcag::relative_luminance(over_black)) - .min(crate::wcag::ratio_from_luminances( - pole_lum, - crate::wcag::relative_luminance(over_white), - )); + let probes = [0.0, 0.039_28, 0.039_280_000_000_000_01, 0.5, 1.0]; + let mut measured_min = f64::INFINITY; + for red in probes { + for green in probes { + for blue in probes { + let background = [red, green, blue]; + let composite = core::array::from_fn(|channel| { + let tint_byte = (tint[channel] * 255.0).round(); + let background_byte_scale = background[channel] * 255.0; + (background_byte_scale + m.alpha() * (tint_byte - background_byte_scale)) + / 255.0 + }); + measured_min = measured_min.min(crate::wcag::ratio_from_luminances( + pole_lum, + crate::wcag::relative_luminance(composite), + )); + } + } + } + assert!(m.worst_contrast() <= measured_min); assert!( - (recomputed - m.worst_contrast()).abs() < 1e-9, - "пересчёт {recomputed} != вердикту {}", - m.worst_contrast() + m.worst_contrast() >= 4.5, + "conservative verdict ниже AA-пола" ); - assert!(recomputed >= 4.5 - 1e-9, "пересчёт ниже AA-пола"); } /// Нейтральный материал БАЙТ-в-байт переиспользует тон dj-anchor (та же физика diff --git a/crates/labcolors-core/src/continuity_tests.rs b/crates/labcolors-core/src/continuity_tests.rs index 6207179c..7d7014b9 100644 --- a/crates/labcolors-core/src/continuity_tests.rs +++ b/crates/labcolors-core/src/continuity_tests.rs @@ -133,9 +133,10 @@ fn polarity_break_zone_is_one_step_wide_and_vc_independent() { if let (Some(a), Some(b)) = ( role_lc(&bg0, &table, &vc, Role::LabelPrimary), role_lc(&bg1, &table, &vc, Role::LabelPrimary), - ) && a.signum() != b.signum() - { - flip_steps.push((g, g + 1)); + ) { + if a.signum() != b.signum() { + flip_steps.push((g, g + 1)); + } } } assert_eq!( diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 4c10a341..a43b5861 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -1,11 +1,15 @@ -//! Свечение — добавление СВЕТА, не наложение краски (labui ADR-0002 §5, -//! решение владельца 2026-07-03). +//! Конечный точечный reference-примитив для screen-рецепта glow. +//! +//! Модуль рассчитывает encoded-композит двух слоёв. Он не моделирует физическое +//! излучение, поле размытия или восприятие полного пространственного эффекта: +//! эти утверждения требуют контракта рендеринга и контекста #221. //! //! # Модель //! //! Слой свечения — цвет G с непрозрачностью α и `mix-blend-mode: screen` //! над непрозрачным фоном. CSS Compositing 1 (blend → simple alpha -//! compositing) в device-пространстве даёт покомпонентно: +//! compositing) в объявленном reference-домене encoded sRGB даёт +//! покомпонентно: //! //! ```text //! result = (1−α)·bg + α·screen(G, bg) @@ -15,39 +19,43 @@ //! Свойства по построению (не проверкой): //! - **никогда не темнит**: α·G·(1−bg) ≥ 0 — вырождение «светлый тинт темнит //! светлый фон» у нормальной альфы здесь невозможно конструкцией; -//! - **монотонно и ЛИНЕЙНО по α** на канал — солвер интенсивности -//! тривиально сходится (по перцептивной цели — бисекция, J' монотонен); -//! - асимметрия тем бесплатно: на белом (1−bg)=0 — свечение физически -//! гаснет; на тёмном цветёт. Среда свечения — тёмная тема (закон -//! асимметрии ADR-0002 §1). +//! - **монотонно и ЛИНЕЙНО по α** на канал. Из этого НЕ следует монотонность +//! CAM16 J': солвер поэтому перебирает конечное множество реально эмитируемых +//! sRGB8-композитов, а не предполагает форму перцептивной функции; +//! - на белом (1−bg)=0 слой является точечным no-op именно по формуле screen; +//! это численное свойство reference-профиля, не утверждение о физическом +//! свете или неизвестном рендерере. //! -//! # Контрактные ступени (зеркальная деривация) +//! # Ступени пресета Lab UI //! -//! **glow-k (dark) := фактический композит-шаг shadow-k (light)** — свечение -//! на тёмном обязано давать тот же перцептивный шаг |ΔJ'|, что тень на -//! светлом. Значения ИЗМЕРЕНЫ от -//! владельческих альф стека теней; отображение subtle:=minor, base:=ambient, -//! bloom:=major (penumbra — анатомическая ступень именно тени, у излучения -//! света полутени нет). Ноль придуманных чисел. +//! Текущие |ΔJ′| взяты из точечных композитов клиентского стека теней Lab UI: +//! `subtle:=minor`, `base:=ambient`, `bloom:=major`. Это воспроизводимая +//! история пресета, но не универсальный психофизический закон и не таксономия +//! обобщённого core; перенос в клиентский профиль отслеживается #221. //! //! # Анатомия (двухслойный bloom) //! -//! core — малый радиус, светлота поднята к белому (пересвет центра — сигнатура -//! реального света); halo — большой радиус, оттенок источника на его светлоте. -//! Оттенок оба слоя наследуют от источника: свечение не имеет собственного -//! цвета. +//! В текущем клиентском recipe core — цвет с поднятой к белому светлотой, halo +//! — исходный цвет. Радиусы и размытие здесь отсутствуют: названия обозначают +//! назначение слоёв у потребителя, а не измеренную геометрию. //! -//! Центр подчинён ЕДИНОМУ ЗАКОНУ БАЛАНСА ([`crate::accent_balance`]): его -//! светлота — функциональный пол (полпути к белому по J'), а хрома на этой -//! светлоте — НЕ фикс-доля источника, а МАКСИМАЛЬНАЯ в гамуте -//! (`max_chroma`). Прежняя фикс-дельта «половина M'» размывала -//! центр к серому у пересвета (оттенок терялся); закон держит идентичность так -//! сильно, как позволяет гамут, а вырождение у стены белого честно флагуется -//! примитивом. Ноль новых констант: пол яркости — та же деривация «полпути к -//! белому», хрома — существующий `max_chroma`. +//! Для хроматического источника core строится существующей политикой +//! [`crate::accent_balance`]: midpoint J′ задаёт начальную Oklab-светлоту, chroma +//! берётся на sRGB-границе данного hue. Точная sRGB8-нейтраль остаётся +//! нейтральной: у неё нет hue, который можно было бы честно «усилить». +//! После хроматического преобразования и sRGB8-квантования итоговый J′ не +//! объявляется равным начальному значению — фактическая точечная метрика core +//! возвращается отдельно. Это версионированный recipe, а не оптимум, +//! проверенный на наблюдателях. use crate::lcs::LcsColor; -use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; +use crate::numerics::{ + DecisionGuaranteeV1, NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, +}; +use crate::spaces::oklab::oklab_to_srgb_linear; +use crate::spaces::srgb::{ + decode_8bit, hex_from_srgb, hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_to_xyz, +}; use crate::spaces::vc::ViewingConditions; /// Контрактный шаг glow-subtle (|ΔJ'| композита от фона): зеркало @@ -61,6 +69,166 @@ pub const GLOW_BASE_DJ: f64 = 2.3006; // SSOT-TRACKED — зеркальная деривация от владельческих альф теней. pub const GLOW_BLOOM_DJ: f64 = 13.3251; +/// Версия конечного reference-домена точного точечного композита glow. +/// +/// Это не сертификат конкретного браузера, дисплея или решения CAM16 и не +/// пространственного поля: профиль фиксирует только encoded sRGB8 + screen. +pub const GLOW_COMPOSITE_PROFILE: &str = "encoded-srgb8-screen-v1"; + +/// Диагностическая appearance-модель, используемая legacy-выбором и замерами +/// полного двухслойного результата Glow. Она не является гарантией численного +/// решения и не усиливает сертификат композита. +pub const GLOW_DIAGNOSTIC_PROFILE: &str = "cam16-ucs-jprime-li2017-v1"; + +/// Версия recipe, строящего core-слой из начального CAM16-UCS J′ и Oklab cusp. +/// +/// Профиль идентифицирует только алгоритм построения слоя. Он не является +/// гарантией выбора, appearance-сертификатом или утверждением о пространстве +/// либо браузере. +pub const GLOW_LAYER_RECIPE_PROFILE: &str = "cam16-jprime-oklab-cusp-v1"; + +/// Типизированный идентификатор профиля точного композитинга. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowCompositeProfileV1 { + /// Точечный screen-композит в encoded sRGB8. + EncodedSrgb8ScreenV1, +} + +impl GlowCompositeProfileV1 { + /// Стабильный wire-ключ сертификата. + pub fn key(self) -> &'static str { + match self { + Self::EncodedSrgb8ScreenV1 => GLOW_COMPOSITE_PROFILE, + } + } +} + +/// Гарантия точного сертификата композитинга, независимая от appearance-решения. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowCompositeGuaranteeV1 { + /// Фактический encoded-sRGB8 state и идентичность эмитированной binary64 alpha. + BitExact, +} + +impl GlowCompositeGuaranteeV1 { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + } + } +} + +/// Идентификатор appearance-диагностики. Значение не является гарантией решения. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowDiagnosticProfileV1 { + /// Текущая транскрипция CAM16-UCS J′ из Li et al. 2017. + Cam16UcsJPrimeLi2017V1, +} + +impl GlowDiagnosticProfileV1 { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::Cam16UcsJPrimeLi2017V1 => GLOW_DIAGNOSTIC_PROFILE, + } + } +} + +/// Типизированный идентификатор текущего двухслойного recipe Glow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowLayerRecipeProfileV1 { + /// Начальный midpoint CAM16-UCS J′, переведённый в Oklab lightness; + /// хроматический core берёт ограниченную cusp chroma текущего Oklab hue, + /// halo равен источнику. + Cam16JPrimeOklabCuspV1, +} + +impl GlowLayerRecipeProfileV1 { + /// Стабильный wire-ключ recipe. + pub const fn key(self) -> &'static str { + match self { + Self::Cam16JPrimeOklabCuspV1 => GLOW_LAYER_RECIPE_PROFILE, + } + } +} + +/// Явно выбранный профиль численного решения. `Default` намеренно отсутствует: +/// legacy-путь никогда не должен появляться через fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowDecisionProfileV1 { + /// Стабильный контракт: без sound bound семантическая ветвь не выбирается. + StableV1, + /// Прежний зависящий от CAM16/libm выбор, сохранённый только явно. + LegacyPlatformDependentV1, +} + +impl GlowDecisionProfileV1 { + /// Стабильный config/wire-ключ. + pub const fn key(self) -> &'static str { + match self { + Self::StableV1 => "stable-v1", + Self::LegacyPlatformDependentV1 => "legacy-platform-dependent-v1", + } + } + + /// Разбирает обязательный config-ключ; неизвестный профиль не нормализуется. + pub fn parse(raw: &str) -> Result { + match raw { + "stable-v1" => Ok(Self::StableV1), + "legacy-platform-dependent-v1" => Ok(Self::LegacyPlatformDependentV1), + other => Err(other.to_string()), + } + } +} + +/// Слой, по которому точечный солвер держит целевой ΔJ′. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowConstraintLayer { + /// Внешний ореол; core измеряется отдельно и не выдаётся за ту же цель. + Halo, +} + +impl GlowConstraintLayer { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::Halo => "halo", + } + } +} + +/// Итог проверки цели по конечному reference-домену. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlowTargetStatus { + /// Стабильный точный no-op: все alpha дают байтовое состояние фона, поэтому + /// положительная цель точно недостижима без appearance-выбора. + ExactNoopUnreachable, + /// Явный legacy-выбор CAM16/libm нашёл первое состояние, держащее цель. + LegacyReached, + /// Явный legacy-выбор CAM16/libm не нашёл цель и вернул максимум |ΔJ′|, + /// а при равенстве максимумов — первое состояние по alpha. + LegacyUnreachable, +} + +impl GlowTargetStatus { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::ExactNoopUnreachable => "exact-noop-unreachable", + Self::LegacyReached => "legacy-reached", + Self::LegacyUnreachable => "legacy-unreachable", + } + } +} + /// Ступень контрактного стека свечения (зеркальная деривация, см. шапку). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlowStep { @@ -73,7 +241,7 @@ pub enum GlowStep { } impl GlowStep { - /// Целевой перцептивный шаг |ΔJ'| композита ступени. + /// Целевой модуль ΔJ′ CAM16-UCS изолированного точечного halo-композита. pub fn target_dj(self) -> f64 { match self { GlowStep::Subtle => GLOW_SUBTLE_DJ, @@ -106,120 +274,1083 @@ impl GlowStep { } } -/// Screen-слой над непрозрачным фоном: `bg + α·G·(1−bg)` покомпонентно -/// (device-пространство, закон лестницы — композитит браузер). -pub fn screen_layer_over_encoded(glow: [f64; 3], alpha: f64, bg: [f64; 3]) -> [f64; 3] { - debug_assert!((0.0..=1.0).contains(&alpha), "α вне [0,1]"); - [ +fn validate_encoded_rgb(label: &str, rgb: [f64; 3]) -> Result<(), String> { + for (channel, value) in rgb.into_iter().enumerate() { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(format!( + "{label}[{channel}] вне конечного encoded-sRGB [0,1]: {value}" + )); + } + } + Ok(()) +} + +fn validate_viewing_numerics(vc: &ViewingConditions) -> Result<(), String> { + // Это только числовая защита CAM16-пути. Она не доказывает полноту или + // физическую применимость контекста просмотра: такая граница принадлежит #230. + for (name, value) in [ + ("n", vc.n), + ("aw", vc.aw), + ("nbb", vc.nbb), + ("ncb", vc.ncb), + ("fl", vc.fl), + ("z", vc.z), + ("c", vc.c), + ("nc", vc.nc), + ("fl_pow_025", vc.fl_pow_025), + ("t_inner", vc.t_inner), + ] { + if !value.is_finite() || value <= 0.0 { + return Err(format!( + "ViewingConditions.{name} вне конечного положительного домена: {value}" + )); + } + } + for (channel, value) in vc.rgb_d.into_iter().enumerate() { + if !value.is_finite() || value <= 0.0 { + return Err(format!( + "ViewingConditions.rgb_d[{channel}] вне конечного положительного домена: {value}" + )); + } + } + // Эти поля задокументированы как производные и должны меняться только + // вместе с исходником. `ViewingConditions` пока имеет публичные поля, так + // что проверяем инвариант на внешней границе вместо доверия структуре. + for (name, actual, expected) in [ + ("fl_pow_025", vc.fl_pow_025, vc.fl.powf(0.25)), + ( + "t_inner", + vc.t_inner, + (1.64 - 0.29_f64.powf(vc.n)).powf(0.73), + ), + ] { + if actual.to_bits() != expected.to_bits() { + return Err(format!( + "ViewingConditions.{name} не согласован с исходными полями: {actual} != {expected}" + )); + } + } + Ok(()) +} + +fn validate_lcs_numerics(label: &str, color: &LcsColor) -> Result<(), String> { + for (name, value) in [ + ("jp", color.jp), + ("h_ok", color.h_ok), + ("s", color.s), + ("h_cam", color.h_cam()), + ] { + if !value.is_finite() { + return Err(format!("{label}.{name} не конечен: {value}")); + } + } + Ok(()) +} + +/// Только J′ для горячего пути конечного солвера. Oklab-hue и M′ здесь не +/// участвуют в целевой функции, поэтому полный `LcsColor` был бы лишней работой +/// на каждом из сотен состояний sRGB8. Формула J′ остаётся тем же SSOT из `cam16`. +fn jp_from_srgb8(bytes: [u8; 3], vc: &ViewingConditions) -> Result { + let rgb = bytes.map(decode_8bit); + let (j, _, _) = crate::spaces::cam16::forward(srgb_to_xyz(rgb), vc); + let jp = crate::spaces::cam16::ucs_j(j); + if jp.is_finite() { + Ok(jp) + } else { + Err(format!( + "условия просмотра дали неконечный J′ для {}", + composite_hex(bytes) + )) + } +} + +fn jp_from_hex(hex: &str, vc: &ViewingConditions) -> Result { + let encoded = srgb_encoded_from_hex(hex)?; + jp_from_srgb8(encoded_bytes(encoded), vc) +} + +/// Непрерывный screen-слой над непрозрачным фоном: `bg + α·G·(1−bg)` +/// покомпонентно в encoded sRGB. Это алгебра до финального округления sRGB8; для +/// эмитируемого reference-пикселя используй [`screen_layer_over_srgb8`]. +/// +/// # Errors +/// +/// `Err`, если α или компонент входного encoded-sRGB не конечен либо лежит +/// вне `[0, 1]`. Граница не зажимает мусор: иначе вызывающий получил бы другой +/// цвет без явного сигнала. +pub fn screen_layer_over_encoded( + glow: [f64; 3], + alpha: f64, + bg: [f64; 3], +) -> Result<[f64; 3], String> { + validate_encoded_rgb("glow", glow)?; + validate_encoded_rgb("bg", bg)?; + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok([ bg[0] + alpha * glow[0] * (1.0 - bg[0]), bg[1] + alpha * glow[1] * (1.0 - bg[1]), bg[2] + alpha * glow[2] * (1.0 - bg[2]), - ] + ]) +} + +/// Один канал конечного encoded-sRGB8 screen reference-профиля. +fn screen_channel_over_srgb8(glow: u8, alpha: f64, bg: u8) -> u8 { + (f64::from(bg) + alpha * f64::from(glow) * f64::from(u8::MAX - bg) / f64::from(u8::MAX)).round() + as u8 +} + +/// Screen-слой в конечном reference-домене encoded-sRGB8. +/// +/// Формула вычисляется прямо в шкале байтов и округляется ровно один раз: +/// `round(bg + α·glow·(255−bg)/255)`. Указанный слева направо порядок +/// binary64-операций является частью reference-профиля и совпадает с JS- +/// проверкой официального пакета. Нормализация `byte/255` перед обратным +/// умножением способна сдвинуть граничное значение половинного округления на +/// соседний LSB. +/// +/// # Errors +/// +/// `Err`, если `alpha` не конечна или лежит вне `[0,1]`. +pub fn screen_layer_over_srgb8(glow: [u8; 3], alpha: f64, bg: [u8; 3]) -> Result<[u8; 3], String> { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok(core::array::from_fn(|channel| { + screen_channel_over_srgb8(glow[channel], alpha, bg[channel]) + })) +} + +/// Точный сертификат одного изолированного screen-композита encoded-sRGB8. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlowCompositeCertificateV1 { + tint_srgb8: [u8; 3], + background_srgb8: [u8; 3], + alpha_bits: u64, + alpha_css: String, + composite_srgb8: [u8; 3], +} + +impl GlowCompositeCertificateV1 { + /// Reference-профиль точной арифметики. + pub fn profile(&self) -> GlowCompositeProfileV1 { + GlowCompositeProfileV1::EncodedSrgb8ScreenV1 + } + + /// Точная гарантия не зависит от диагностики или выбора CAM16. + pub fn guarantee(&self) -> GlowCompositeGuaranteeV1 { + GlowCompositeGuaranteeV1::BitExact + } + + /// Эмитируемая тройка байтов tint. + pub fn tint_srgb8(&self) -> [u8; 3] { + self.tint_srgb8 + } + + /// Тройка байтов фона reference-запроса. + pub fn background_srgb8(&self) -> [u8; 3] { + self.background_srgb8 + } + + /// Точная binary64-идентичность alpha. + pub fn alpha_bits(&self) -> u64 { + self.alpha_bits + } + + /// Каноническая CSS-запись той же alpha. + pub fn alpha_css(&self) -> &str { + &self.alpha_css + } + + /// Точная тройка байтов композита. + pub fn composite_srgb8(&self) -> [u8; 3] { + self.composite_srgb8 + } +} + +fn composite_certificate( + tint_srgb8: [u8; 3], + background_srgb8: [u8; 3], + alpha: f64, + alpha_css: String, + composite_srgb8: [u8; 3], +) -> GlowCompositeCertificateV1 { + GlowCompositeCertificateV1 { + tint_srgb8, + background_srgb8, + alpha_bits: alpha.to_bits(), + alpha_css, + composite_srgb8, + } +} + +/// Изолированный точечный замер одного screen-слоя в reference-профиле glow. +/// Пространственное перекрытие core/halo сюда намеренно не входит: без +/// геометрии и порядка слоёв его нельзя восстановить честно. +pub(crate) struct ScreenLayerMeasurement { + pub(crate) composite_hex: String, + pub(crate) achieved_dj: f64, + pub(crate) certificate: GlowCompositeCertificateV1, +} + +pub(crate) fn measure_screen_layer_at_alpha( + tint_hex: &str, + bg_hex: &str, + alpha: f64, + vc: &ViewingConditions, +) -> Result { + validate_viewing_numerics(vc)?; + let tint = encoded_bytes(srgb_encoded_from_hex(tint_hex)?); + let bg = encoded_bytes(srgb_encoded_from_hex(bg_hex)?); + let composite_srgb8 = screen_layer_over_srgb8(tint, alpha, bg)?; + let composite_hex = composite_hex(composite_srgb8); + let alpha_css = crate::css_alpha_value(alpha)?; + let bg_jp = jp_from_hex(bg_hex, vc)?; + let composite_jp = jp_from_hex(&composite_hex, vc)?; + let achieved_dj = (composite_jp - bg_jp).abs(); + if !achieved_dj.is_finite() { + return Err(format!( + "условия просмотра дали неконечный ΔJ' для {composite_hex}" + )); + } + Ok(ScreenLayerMeasurement { + composite_hex, + achieved_dj, + certificate: composite_certificate(tint, bg, alpha, alpha_css, composite_srgb8), + }) +} + +const ALPHA_ZERO_BITS: u64 = 0.0_f64.to_bits(); +const ALPHA_ONE_BITS: u64 = 1.0_f64.to_bits(); +const MAX_QUANTISED_COMPOSITE_STATES: u16 = 766; + +#[derive(Debug, Clone, Copy)] +struct QuantisedComposite { + bytes: [u8; 3], + lower_bits: u64, + upper_bits: u64, + upper_inclusive: bool, +} + +impl QuantisedComposite { + /// Выбирает внутреннюю representable alpha около численного центра state. + /// Если midpoint округлился на исключённую верхнюю границу, берётся её + /// непосредственный predecessor; singleton при alpha=1 остаётся допустим. + fn canonical_alpha(self) -> f64 { + let lower = f64::from_bits(self.lower_bits); + let upper = f64::from_bits(self.upper_bits); + if self.lower_bits == self.upper_bits { + debug_assert!(self.upper_inclusive); + return lower; + } + + let midpoint = lower + (upper - lower) * 0.5; + let mut bits = midpoint.to_bits().max(self.lower_bits); + if self.upper_inclusive { + bits = bits.min(self.upper_bits); + } else if bits >= self.upper_bits { + bits = self.upper_bits - 1; + } + f64::from_bits(bits) + } +} + +fn encoded_bytes(rgb: [f64; 3]) -> [u8; 3] { + rgb.map(|channel| (channel * 255.0).round() as u8) +} + +fn composite_hex(bytes: [u8; 3]) -> String { + format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]) +} + +/// Поток всех состояний, достижимых representable binary64-alpha в объявленном +/// operation order [`screen_layer_over_srgb8`]. +/// +/// Алгебраически равные рациональные стенки нельзя склеивать: разные +/// факторизации `G·(255−B)` могут пересечь half-tie на разных `f64`. Поэтому +/// каждый channel transition находит первый passing binary64 точным lower-bound +/// по упорядоченным положительным битам. Каждый переход увеличивает хотя бы один +/// байт, значит состояний не больше `1 + 3·255 = 766`; память остаётся O(1). +struct QuantisedComposites { + glow: [u8; 3], + background: [u8; 3], + final_bytes: [u8; 3], + bytes: [u8; 3], + next_boundaries: [Option; 3], + lower_bits: u64, + emitted_states: u16, + finished: bool, +} + +/// Binary64-округление рациональной half-wall, используемое только как seed +/// экспоненциального поиска. Фактическую границу всегда определяет compositor. +fn boundary_seed_bits(glow: u8, background: u8, next_value: u8) -> u64 { + let background = f64::from(background); + let numerator = (f64::from(next_value) - background - 0.5) * f64::from(u8::MAX); + let denominator = f64::from(glow) * (f64::from(u8::MAX) - background); + debug_assert!(denominator > 0.0); + (numerator / denominator).clamp(0.0, 1.0).to_bits() +} + +impl QuantisedComposites { + fn new(glow: [u8; 3], background: [u8; 3]) -> Self { + let final_bytes = core::array::from_fn(|channel| { + screen_channel_over_srgb8(glow[channel], 1.0, background[channel]) + }); + let mut stream = Self { + glow, + background, + final_bytes, + bytes: background, + next_boundaries: [None; 3], + lower_bits: ALPHA_ZERO_BITS, + emitted_states: 0, + finished: false, + }; + for channel in 0..3 { + stream.next_boundaries[channel] = stream.next_boundary(channel); + } + stream + } + + fn next_boundary(&self, channel: usize) -> Option { + let next_value = self.bytes[channel].checked_add(1)?; + if self.final_bytes[channel] < next_value { + return None; + } + + debug_assert!( + screen_channel_over_srgb8( + self.glow[channel], + f64::from_bits(self.lower_bits), + self.background[channel], + ) < next_value, + "текущий state уже пересёк искомую channel boundary" + ); + let passes = |bits| { + screen_channel_over_srgb8( + self.glow[channel], + f64::from_bits(bits), + self.background[channel], + ) >= next_value + }; + + // Рациональная half-wall служит только seed, а не определением state: + // фиксированный binary64 operation order может сдвинуть first-passing + // alpha относительно этого seed. Экспоненциальный поиск обязательно находит + // bracket между уже известными failing lower и passing 1, после чего + // обычный lower-bound остаётся точным независимо от качества seed. + let seed = boundary_seed_bits(self.glow[channel], self.background[channel], next_value) + .clamp(self.lower_bits.saturating_add(1), ALPHA_ONE_BITS); + + let (mut failing, mut passing); + if passes(seed) { + passing = seed; + let mut stride = 1_u64; + loop { + let probe = passing.saturating_sub(stride).max(self.lower_bits); + if !passes(probe) { + failing = probe; + break; + } + passing = probe; + stride = stride.saturating_mul(2); + } + } else { + failing = seed; + let mut stride = 1_u64; + loop { + let probe = failing.saturating_add(stride).min(ALPHA_ONE_BITS); + if passes(probe) { + passing = probe; + break; + } + failing = probe; + stride = stride.saturating_mul(2); + } + } + + while passing - failing > 1 { + let middle = failing + (passing - failing) / 2; + if passes(middle) { + passing = middle; + } else { + failing = middle; + } + } + Some(passing) + } + + fn emit(&mut self, state: QuantisedComposite) -> Result, String> { + self.emitted_states += 1; + if self.emitted_states > MAX_QUANTISED_COMPOSITE_STATES { + return Err(format!( + "binary64 Glow stream превысил доказанную границу {MAX_QUANTISED_COMPOSITE_STATES}" + )); + } + Ok(Some(state)) + } + + /// Возвращает очередной интервал `[lower, upper)`; последний интервал + /// замкнут справа и заканчивается на `1`. Вместе поглощаются только + /// transition с одним и тем же первым passing `f64`, а не одинаковые + /// rational real-number walls. + fn next_state(&mut self) -> Result, String> { + if self.finished { + return Ok(None); + } + + let upper = self.next_boundaries.into_iter().flatten().min(); + + let Some(upper) = upper else { + self.finished = true; + return self.emit(QuantisedComposite { + bytes: self.bytes, + lower_bits: self.lower_bits, + upper_bits: ALPHA_ONE_BITS, + upper_inclusive: true, + }); + }; + + let state = QuantisedComposite { + bytes: self.bytes, + lower_bits: self.lower_bits, + upper_bits: upper, + upper_inclusive: false, + }; + let previous = self.bytes; + self.bytes = screen_layer_over_srgb8(self.glow, f64::from_bits(upper), self.background)?; + self.lower_bits = upper; + for (channel, previous_byte) in previous.into_iter().enumerate() { + if self.bytes[channel] < previous_byte { + return Err(format!( + "binary64 screen stream уменьшил канал {channel}: {} -> {}", + previous_byte, self.bytes[channel] + )); + } + if self.bytes[channel] > previous_byte { + self.next_boundaries[channel] = self.next_boundary(channel); + } else if self.next_boundaries[channel] == Some(upper) { + return Err(format!( + "channel {channel} не изменился на собственной first-passing boundary" + )); + } + } + self.emit(state) + } +} + +/// Тестовый сборщик сохраняет удобный независимый оракул, но `Vec` и сортировка +/// не входят в production-сборку WASM. +#[cfg(test)] +fn quantised_composites( + glow: [u8; 3], + background: [u8; 3], +) -> Result, String> { + let mut stream = QuantisedComposites::new(glow, background); + let mut states = Vec::new(); + while let Some(state) = stream.next_state()? { + states.push(state); + } + Ok(states) } /// Результат glow-солвера (ADR-0002 «честный результат», закон 2). #[derive(Debug, Clone, PartialEq)] pub struct GlowSolve { - /// Интенсивность слоя `(0, 1]` — α screen-слоя. - pub alpha: f64, + /// Каноническая интенсивность слоя `(0, 1]`: центр устойчивого интервала + /// выбранного sRGB8-композита. Это не «оптимальный вкус», а максимальный + /// численный запас до ближайшей границы квантования. + alpha: f64, + /// Каноническая CSS-запись той же alpha; хранится вместе с числом, чтобы + /// нижележащий потребитель не вводил собственную политику округления. + alpha_css: String, + /// Запрошенный |ΔJ′| точечного композита halo. + target_dj: f64, /// Фактический |ΔJ'| композита от фона, замерен на эмитируемом hex. - pub achieved_dj: f64, + achieved_dj: f64, /// Композит `screen(tint, α)` над фоном, `#RRGGBB`. - pub composite_hex: String, - /// Деградация: цель недостижима даже при α = 1 (например, фон близок к - /// белому — screen гаснет физически); возвращён ближайший достижимый - /// результат с честным флагом, НЕ ошибка и НЕ молчание. - pub degraded: bool, + composite_hex: String, + /// Точное свидетельство композитинга, независимое от legacy appearance-решения. + composite_certificate: GlowCompositeCertificateV1, + /// Appearance-диагностика, реально использованная только для выбора. + /// Точный no-op полностью её обходит. + selection_diagnostic_profile: Option, + /// Исход проверки цели с provenance. Точный no-op объявляет точную + /// недостижимость по одному байтовому состоянию; явный legacy-путь объявляет + /// достижение либо возвращает выбранный CAM16 максимум с legacy-маркером. + status: GlowTargetStatus, } -/// Решить интенсивность screen-слоя под целевой перцептивный шаг. +impl GlowSolve { + /// Каноническая интенсивность, выбранная внутри устойчивого sRGB8-интервала. + pub fn alpha(&self) -> f64 { + self.alpha + } + + /// CSS-запись, восстанавливающая ту же binary64 alpha. + pub fn alpha_css(&self) -> &str { + &self.alpha_css + } + + /// Запрошенный |ΔJ′| halo-композита. + pub fn target_dj(&self) -> f64 { + self.target_dj + } + + /// Фактически достигнутый |ΔJ′| на возвращённом sRGB8-композите. + pub fn achieved_dj(&self) -> f64 { + self.achieved_dj + } + + /// Возвращённый reference-композит halo. + pub fn composite_hex(&self) -> &str { + &self.composite_hex + } + + /// Точный сертификат точечного композита выбранного состояния. + pub fn composite_certificate(&self) -> &GlowCompositeCertificateV1 { + &self.composite_certificate + } + + /// Типизированный результат проверки цели. + pub fn status(&self) -> GlowTargetStatus { + self.status + } + + /// Геттер совместимости прежнего булева контракта: цель недостижима как при + /// точном no-op, так и при явном legacy-исходе с максимумом. + pub fn degraded(&self) -> bool { + matches!( + self.status, + GlowTargetStatus::ExactNoopUnreachable | GlowTargetStatus::LegacyUnreachable + ) + } + + /// Слой, по которому решалась цель. + pub fn constraint_layer(&self) -> GlowConstraintLayer { + GlowConstraintLayer::Halo + } + + /// Точный профиль композита; идентификатор диагностики выбора читается отдельно. + pub fn composite_profile(&self) -> GlowCompositeProfileV1 { + self.composite_certificate.profile() + } + + /// Идентификатор диагностики выбора, только если точечный солвер действительно + /// вызвал эту модель для выбора target/max. + pub fn selection_diagnostic_profile(&self) -> Option { + self.selection_diagnostic_profile + } +} + +struct ScreenPointInputs { + glow: [u8; 3], + background: [u8; 3], + slopes: [u16; 3], +} + +/// Разобрать точные точечные входы encoded-sRGB8 и вывести наклоны их каналов. +fn screen_point_inputs(glow_tint_hex: &str, bg_hex: &str) -> Result { + let glow_bytes = encoded_bytes(srgb_encoded_from_hex(glow_tint_hex)?); + let bg_bytes = encoded_bytes(srgb_encoded_from_hex(bg_hex)?); + let slopes = core::array::from_fn::<_, 3, _>(|channel| { + u16::from(glow_bytes[channel]) * u16::from(u8::MAX - bg_bytes[channel]) + }); + Ok(ScreenPointInputs { + glow: glow_bytes, + background: bg_bytes, + slopes, + }) +} + +fn slopes_are_exact_srgb8_noop(slopes: [u16; 3]) -> bool { + // Screen монотонен по alpha. Поэтому каждая alpha квантуется в фон тогда и + // только тогда, когда endpoint при alpha=1 остаётся строго ниже первой + // стенки половины LSB: G*(255-B)/255 < 1/2. + slopes + .into_iter() + .all(|slope| 2 * u32::from(slope) < u32::from(u8::MAX)) +} + +/// Предикат точной повторной проверки стабильного точечного решения Glow. +/// +/// Возвращает `true` тогда и только тогда, когда монотонный screen-endpoint +/// encoded-sRGB8 при alpha=1 остаётся ниже первой стенки половины LSB в каждом +/// канале. Сюда входят нулевые наклоны и ненулевые наклоны меньше LSB. +/// Appearance-модель и epsilon в проверке не участвуют. +/// +/// # Errors +/// +/// Возвращает `Err` для невалидного tint или фона вместо их нормализации. +pub fn screen_point_is_exact_noop(glow_tint_hex: &str, bg_hex: &str) -> Result { + let inputs = screen_point_inputs(glow_tint_hex, bg_hex)?; + Ok(slopes_are_exact_srgb8_noop(inputs.slopes)) +} + +/// Решить интенсивность screen-слоя с явным профилем численного решения. /// -/// Бисекция по α ∈ [0, 1]: J' композита монотонно растёт по α (screen -/// монотонен покомпонентно, J' монотонен по яркости стимула). Замер цели — -/// на КВАНТОВАННОМ композите (закон движка: честный шаг меряется на -/// отданном hex). +/// `StableV1` не вызывает CAM16 для нетривиального участка target/max, пока для +/// него нет sound bound, и возвращает типизированный `Indeterminate`. +/// Единственный точный определённый стабильный случай — точечный no-op: все +/// alpha дают тот же фон, поэтому `ΔJ′ = 0` следует из равенства байтовых +/// состояний без appearance-математики. `LegacyPlatformDependentV1` явно +/// сохраняет прежний runtime и помечает его гарантию; неявная legacy-обёртка +/// отсутствует. /// /// # Errors /// -/// `Err` — программный мусор (закон 3 ADR-0002): невалидный hex, цель ≤ 0. +/// `Err` — невалидный hex или target либо нарушенное внутреннее постусловие +/// legacy-пути. pub fn solve_screen_alpha_for_dj( + glow_tint_hex: &str, + bg_hex: &str, + target_dj: f64, + profile: GlowDecisionProfileV1, + vc: &ViewingConditions, +) -> Result, String> { + match profile { + GlowDecisionProfileV1::LegacyPlatformDependentV1 => { + return Ok(NumericalDecisionV1::Determinate { + value: solve_screen_alpha_for_dj_legacy(glow_tint_hex, bg_hex, target_dj, vc)?, + guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, + }); + } + GlowDecisionProfileV1::StableV1 => {} + } + + if !target_dj.is_finite() || target_dj <= 0.0 { + return Err(format!("целевой шаг вне домена: {target_dj}")); + } + let ScreenPointInputs { + glow: glow_bytes, + background: bg_bytes, + slopes, + } = screen_point_inputs(glow_tint_hex, bg_hex)?; + if slopes_are_exact_srgb8_noop(slopes) { + // Любая alpha из [0,1] даёт тот же байтовый composite; 0.5 — + // канонический средний представитель, а не измеренная величина. + let alpha = 0.5; + let alpha_css = crate::css_alpha_value(alpha)?; + let composite_srgb8 = bg_bytes; + return Ok(NumericalDecisionV1::Determinate { + value: GlowSolve { + alpha, + alpha_css: alpha_css.clone(), + target_dj, + achieved_dj: 0.0, + composite_hex: composite_hex(composite_srgb8), + composite_certificate: composite_certificate( + glow_bytes, + bg_bytes, + alpha, + alpha_css, + composite_srgb8, + ), + selection_diagnostic_profile: None, + status: GlowTargetStatus::ExactNoopUnreachable, + }, + guarantee: DecisionGuaranteeV1::BitExact, + }); + } + + Ok(NumericalDecisionV1::Indeterminate { + site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + }) +} + +/// Явный зависящий от CAM16/libm legacy-путь target/max. +/// +/// Канальные переходы screen находятся как first-passing binary64 границы того +/// же operation order, который исполняет публичный compositor. Солвер проверяет +/// все достижимые композиты в порядке α и потому не предполагает монотонность +/// CAM16 J'. Если цель недостижима, возвращается глобальный максимум |ΔJ'| среди +/// достижимых состояний; равные максимумы детерминированно разрешаются первым +/// состоянием по alpha. +/// +/// # Errors +/// +/// `Err` — программный мусор (закон 3 ADR-0002): невалидный hex, неконечная +/// или неположительная цель либо нечисловой результат переданных условий +/// просмотра. +fn solve_screen_alpha_for_dj_legacy( glow_tint_hex: &str, bg_hex: &str, target_dj: f64, vc: &ViewingConditions, ) -> Result { - if target_dj.is_nan() || target_dj <= 0.0 { + if !target_dj.is_finite() || target_dj <= 0.0 { return Err(format!("целевой шаг вне домена: {target_dj}")); } + validate_viewing_numerics(vc)?; let glow = srgb_encoded_from_hex(glow_tint_hex)?; let bg = srgb_encoded_from_hex(bg_hex)?; - let bg_jp = LcsColor::from_hex_with_vc(bg_hex, vc)?.jp; + let glow_bytes = encoded_bytes(glow); + let bg_bytes = encoded_bytes(bg); + let bg_jp = jp_from_srgb8(bg_bytes, vc)?; - let dj_at = |alpha: f64| -> Result<(f64, String), String> { - let hex = hex_from_srgb_encoded(screen_layer_over_encoded(glow, alpha, bg)); - let jp = LcsColor::from_hex_with_vc(&hex, vc)?.jp; - Ok(((jp - bg_jp).abs(), hex)) + let measured = |bytes: [u8; 3]| -> Result { + let jp = jp_from_srgb8(bytes, vc)?; + let dj = (jp - bg_jp).abs(); + if !dj.is_finite() { + return Err(format!( + "условия просмотра дали неконечный ΔJ' для {}", + composite_hex(bytes) + )); + } + Ok(dj) }; - let (max_dj, max_hex) = dj_at(1.0)?; - if max_dj < target_dj { - // Честная деградация: ближайший достижимый шаг + флаг (закон 2). - return Ok(GlowSolve { - alpha: 1.0, - achieved_dj: max_dj, - composite_hex: max_hex, - degraded: true, - }); - } + let mut states = QuantisedComposites::new(glow_bytes, bg_bytes); + let mut best: Option<(QuantisedComposite, f64)> = None; + let mut selected = None; - let (mut lo, mut hi) = (0.0_f64, 1.0_f64); - for _ in 0..48 { - let mid = 0.5 * (lo + hi); - let (dj, _) = dj_at(mid)?; - if dj < target_dj { - lo = mid; - } else { - hi = mid; + while let Some(state) = states.next_state()? { + let achieved_dj = measured(state.bytes)?; + if achieved_dj >= target_dj { + selected = Some((state, achieved_dj)); + break; } + if best + .as_ref() + .is_none_or(|(_, best_dj)| achieved_dj > *best_dj) + { + best = Some((state, achieved_dj)); + } + } + + let (state, achieved_dj, status) = if let Some(selected) = selected { + let (state, achieved_dj) = selected; + (state, achieved_dj, GlowTargetStatus::LegacyReached) + } else { + let (state, achieved_dj) = + best.ok_or_else(|| "конечное множество композитов оказалось пустым".to_string())?; + (state, achieved_dj, GlowTargetStatus::LegacyUnreachable) + }; + let selected_hex = composite_hex(state.bytes); + let alpha = state.canonical_alpha(); + + // Канонический CSS-сериализатор хранит ту же binary64 alpha. Production- + // проверка повторно композитит исходное число; обратное чтение строки + // проверяют граничные и межъязыковые тесты, не затягивая dec2flt-парсер в WASM. + let alpha_css = crate::css_alpha_value(alpha)?; + let roundtrip_srgb8 = screen_layer_over_srgb8(glow_bytes, alpha, bg_bytes)?; + let roundtrip_hex = composite_hex(roundtrip_srgb8); + if roundtrip_hex != selected_hex { + return Err(format!( + "выбранная alpha не воспроизвела state: ожидался {selected_hex}, получен {roundtrip_hex}" + )); } - // Верхняя сторона брекета: на 8-битной сетке шаг дискретен, и середина - // могла бы лечь НИЖЕ цели без флага — hi по инварианту бисекции даёт - // квантованный шаг ≥ цели (ближайший достижимый сверху, закон 2 ADR-0002). - let alpha = hi; - let (achieved_dj, composite_hex) = dj_at(alpha)?; + Ok(GlowSolve { alpha, + alpha_css: alpha_css.clone(), + target_dj, achieved_dj, - composite_hex, - degraded: false, + composite_hex: selected_hex, + composite_certificate: composite_certificate( + glow_bytes, + bg_bytes, + alpha, + alpha_css, + roundtrip_srgb8, + ), + selection_diagnostic_profile: Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + status, }) } -/// Двухслойная анатомия свечения от источника: `(core_hex, halo_hex)`. +/// Версионированный двухслойный recipe от источника: `(core_hex, halo_hex)`. /// -/// halo — сам источник (его оттенок на его светлоте); core — переэкспонирование -/// по ЗАКОНУ БАЛАНСА: светлота = функциональный пол «полпути к белому по J'» -/// (`J'core = (J'src + 100)/2`), а хрома на ней — МАКСИМАЛЬНАЯ в гамуте для -/// оттенка источника ([`crate::accent_balance::accent_balanced`]), не фикс-доля. -/// Оттенок (h_ok) — источника: свечение не имеет собственного цвета. +/// В recipe v1 halo буквально равен источнику. Для core арифметическая середина +/// J′ источника и 100 задаёт начальную Oklab-светлоту. У хроматического источника +/// chroma — sRGB-граница его Oklab hue через +/// [`crate::accent_balance::accent_balanced`]; у точного sRGB8-нейтраля chroma +/// остаётся нулевой, потому его численный hue не несёт цветового смысла. +/// Фактический J′ эмитированного core измеряется вызывающим кодом: он не обязан +/// совпасть с начальным значением после смены координат и sRGB8-квантования. +/// Это recipe, а не наблюдательная модель красоты или геометрии свечения. pub fn glow_layers_from_source( source_hex: &str, vc: &ViewingConditions, ) -> Result<(String, String), String> { - let src = LcsColor::from_hex_with_vc(source_hex, vc)?; - // Функциональный пол яркости центра: полпути к белому по J' (пересвет). + validate_viewing_numerics(vc)?; + let source_encoded = srgb_encoded_from_hex(source_hex)?; + let source_bytes = encoded_bytes(source_encoded); + let canonical_source_hex = hex_from_srgb_encoded(source_encoded); + let src = LcsColor::from_hex_with_vc(&canonical_source_hex, vc)?; + validate_lcs_numerics("source", &src)?; let jp_core = (src.jp + 100.0) * 0.5; + if !jp_core.is_finite() { + return Err(format!("core J′ seed не конечен: {jp_core}")); + } let l_core = crate::scale::jp_to_oklab_l(jp_core, vc); - // ЗАКОН БАЛАНСА: на этой яркости — максимум хромы оттенка источника (не ×0.5, - // размывавшая центр к серому). Идентичность держится, вырождение — флагом. - let core = crate::accent_balance::accent_balanced(l_core, src.h_ok, vc).color; - Ok((core.to_hex_with_vc(vc), source_hex.to_string())) + if !l_core.is_finite() || !(0.0..=1.0).contains(&l_core) { + return Err(format!("core Oklab L вне конечного [0,1]: {l_core}")); + } + + let core_rgb = if source_bytes[0] == source_bytes[1] && source_bytes[1] == source_bytes[2] { + // У точного sRGB8-нейтраля оттенка нет. Число atan2 от матричного шума + // нельзя превращать в насыщенный core: hue-отсутствие сохраняется. + oklab_to_srgb_linear([l_core, 0.0, 0.0]) + } else { + let balanced = crate::accent_balance::accent_balanced(l_core, src.h_ok, vc); + validate_lcs_numerics("core", &balanced.color)?; + for (name, value) in [ + ("l_ok", balanced.l_ok), + ("c_ok", balanced.c_ok), + ("hue_deg", balanced.hue_deg), + ] { + if !value.is_finite() { + return Err(format!("balanced core {name} не конечен: {value}")); + } + } + let hue = balanced.hue_deg.to_radians(); + oklab_to_srgb_linear([ + balanced.l_ok, + balanced.c_ok * hue.cos(), + balanced.c_ok * hue.sin(), + ]) + }; + if core_rgb.into_iter().any(|channel| !channel.is_finite()) { + return Err(format!("core linear-sRGB не конечен: {core_rgb:?}")); + } + let core_hex = hex_from_srgb(core_rgb); + Ok((core_hex, canonical_source_hex)) } #[cfg(test)] mod tests { use super::*; + #[test] + fn status_and_recipe_keys_preserve_their_provenance() { + assert_eq!( + GlowTargetStatus::ExactNoopUnreachable.key(), + "exact-noop-unreachable" + ); + assert_eq!(GlowTargetStatus::LegacyReached.key(), "legacy-reached"); + assert_eq!( + GlowTargetStatus::LegacyUnreachable.key(), + "legacy-unreachable" + ); + assert_eq!( + GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1.key(), + "cam16-jprime-oklab-cusp-v1" + ); + } + + fn solve_legacy( + tint: &str, + background: &str, + target_dj: f64, + vc: &ViewingConditions, + ) -> Result { + match solve_screen_alpha_for_dj( + tint, + background, + target_dj, + GlowDecisionProfileV1::LegacyPlatformDependentV1, + vc, + )? { + NumericalDecisionV1::Determinate { + value, + guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, + } => Ok(value), + other => Err(format!("explicit legacy profile дал {other:?}")), + } + } + + #[test] + fn stable_profile_returns_bound_unavailable_indeterminate() { + let vc = ViewingConditions::srgb(); + let decision = solve_screen_alpha_for_dj( + "#C0B2FA", + "#000000", + GLOW_BASE_DJ, + GlowDecisionProfileV1::StableV1, + &vc, + ) + .expect("валидный запрос обязан дать typed numerical decision"); + assert!(matches!( + decision, + NumericalDecisionV1::Indeterminate { + site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + } + )); + } + + #[test] + fn stable_indeterminate_path_does_not_consult_cam16_viewing_conditions() { + let mut invalid_vc = ViewingConditions::srgb(); + invalid_vc.n = f64::NAN; + + let stable = solve_screen_alpha_for_dj( + "#C0B2FA", + "#000000", + GLOW_BASE_DJ, + GlowDecisionProfileV1::StableV1, + &invalid_vc, + ) + .expect("stable-v1 must stop before the unbounded CAM16 site"); + assert!(matches!( + stable, + NumericalDecisionV1::Indeterminate { + site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + } + )); + + assert!( + solve_screen_alpha_for_dj( + "#C0B2FA", + "#000000", + GLOW_BASE_DJ, + GlowDecisionProfileV1::LegacyPlatformDependentV1, + &invalid_vc, + ) + .is_err() + ); + } + + #[test] + fn stable_point_noop_is_determinate_from_exact_bytes_only() { + let mut invalid_vc = ViewingConditions::srgb(); + invalid_vc.n = f64::NAN; + let decision = solve_screen_alpha_for_dj( + "#4A8FFF", + "#FFFFFF", + GLOW_BASE_DJ, + GlowDecisionProfileV1::StableV1, + &invalid_vc, + ) + .expect("white point screen composite is exact and does not need CAM16"); + + let NumericalDecisionV1::Determinate { + value, + guarantee: DecisionGuaranteeV1::BitExact, + } = decision + else { + panic!("exact point no-op must be a BitExact determinate decision"); + }; + assert_eq!(value.status(), GlowTargetStatus::ExactNoopUnreachable); + assert!(value.selection_diagnostic_profile().is_none()); + assert_eq!(value.achieved_dj().to_bits(), 0.0_f64.to_bits()); + assert_eq!(value.composite_hex(), "#FFFFFF"); + assert_eq!(value.composite_certificate().composite_srgb8(), [255; 3]); + } + + #[test] + fn stable_point_noop_follows_exact_endpoint_law_not_white_special_case() { + let mut invalid_vc = ViewingConditions::srgb(); + invalid_vc.n = f64::NAN; + for (tint, background) in [ + ("#000000", "#123456"), + // Каждый канал имеет G*(255-B)=0, но ни один операнд не является + // однородным endpoint: R/B используют G=0, а G использует B=255. + ("#00FF00", "#12FF34"), + // Ненулевой красный наклон 1 остаётся ниже точной стенки половины LSB. + ("#010000", "#FE0000"), + ] { + assert!(screen_point_is_exact_noop(tint, background).unwrap()); + let decision = solve_screen_alpha_for_dj( + tint, + background, + GLOW_BASE_DJ, + GlowDecisionProfileV1::StableV1, + &invalid_vc, + ) + .expect("quantised screen no-op is exact and must not consult CAM16"); + assert!(matches!( + decision, + NumericalDecisionV1::Determinate { + value, + guarantee: DecisionGuaranteeV1::BitExact, + } if value.status() == GlowTargetStatus::ExactNoopUnreachable + && value.achieved_dj().to_bits() == 0.0_f64.to_bits() + && value.composite_hex() == background + && value.selection_diagnostic_profile().is_none() + )); + } + assert!(screen_point_is_exact_noop("#7F0000", "#FE0000").unwrap()); + assert!(!screen_point_is_exact_noop("#800000", "#FE0000").unwrap()); + assert!(screen_point_is_exact_noop("not-a-colour", "#123456").is_err()); + } + + #[test] + fn exact_noop_predicate_matches_every_one_channel_endpoint() { + for glow in 0..=u8::MAX { + for background in 0..=u8::MAX { + let tint_hex = format!("#{glow:02X}0000"); + let bg_hex = format!("#{background:02X}0000"); + let endpoint = + screen_layer_over_srgb8([glow, 0, 0], 1.0, [background, 0, 0]).unwrap(); + assert_eq!( + screen_point_is_exact_noop(&tint_hex, &bg_hex).unwrap(), + endpoint == [background, 0, 0], + "glow={glow}, background={background}" + ); + } + } + } + + /// Независимый оракул не использует production cursor/boundary helper. Для + /// каждого следующего channel byte он отдельно делает lower-bound по всему + /// диапазону положительных `f64` через публичный композитор, затем объединяет + /// найденные bit-boundaries и удаляет только соседние дубликаты hex. + fn oracle_states( + tint_hex: &str, + bg_hex: &str, + vc: &ViewingConditions, + ) -> Vec<(f64, String, f64)> { + let tint = srgb_encoded_from_hex(tint_hex).unwrap(); + let bg = srgb_encoded_from_hex(bg_hex).unwrap(); + let tint_bytes = encoded_bytes(tint); + let bg_bytes = encoded_bytes(bg); + let mut boundaries = vec![ALPHA_ZERO_BITS, ALPHA_ONE_BITS]; + + for channel in 0..3 { + let final_value = screen_layer_over_srgb8(tint_bytes, 1.0, bg_bytes).unwrap()[channel]; + for value in (u16::from(bg_bytes[channel]) + 1)..=u16::from(final_value) { + let mut failing = ALPHA_ZERO_BITS; + let mut passing = ALPHA_ONE_BITS; + while passing - failing > 1 { + let middle = failing + (passing - failing) / 2; + let output = + screen_layer_over_srgb8(tint_bytes, f64::from_bits(middle), bg_bytes) + .unwrap()[channel]; + if output >= value as u8 { + passing = middle; + } else { + failing = middle; + } + } + boundaries.push(passing); + } + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let bg_jp = LcsColor::from_hex_with_vc(bg_hex, vc).unwrap().jp; + let mut states: Vec<(f64, String, f64)> = Vec::new(); + for boundary in boundaries { + let alpha = f64::from_bits(boundary); + let hex = composite_hex(screen_layer_over_srgb8(tint_bytes, alpha, bg_bytes).unwrap()); + if states + .last() + .is_some_and(|(_, previous, _)| previous == &hex) + { + continue; + } + let jp = LcsColor::from_hex_with_vc(&hex, vc).unwrap().jp; + states.push((alpha, hex, (jp - bg_jp).abs())); + } + states + } + /// Screen никогда не темнит — свойство конструкции, sweep-замок. #[test] fn screen_never_darkens_any_channel() { @@ -231,7 +1362,7 @@ mod tests { let bg = srgb_encoded_from_hex(bg_hex).unwrap(); for i in 0..=20 { let a = f64::from(i) / 20.0; - let out = screen_layer_over_encoded(tint, a, bg); + let out = screen_layer_over_encoded(tint, a, bg).unwrap(); for ch in 0..3 { assert!( out[ch] >= bg[ch] - 1e-12, @@ -244,25 +1375,447 @@ mod tests { } } + /// Публичная числовая граница отвергает мусор, а не паникует в debug и не + /// превращает его в другой цвет через clamp. + #[test] + fn screen_rejects_every_non_finite_or_out_of_domain_component() { + let valid = [0.25, 0.5, 0.75]; + for alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.1] { + assert!(screen_layer_over_encoded(valid, alpha, valid).is_err()); + assert!(screen_layer_over_srgb8([1, 2, 3], alpha, [4, 5, 6]).is_err()); + } + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.1] { + let mut rgb = valid; + rgb[1] = bad; + assert!(screen_layer_over_encoded(rgb, 0.5, valid).is_err()); + assert!(screen_layer_over_encoded(valid, 0.5, rgb).is_err()); + } + assert!(screen_layer_over_encoded(valid, 0.0, valid).is_ok()); + assert!(screen_layer_over_encoded(valid, 1.0, valid).is_ok()); + assert!(screen_layer_over_srgb8([1, 2, 3], 0.0, [4, 5, 6]).is_ok()); + assert!(screen_layer_over_srgb8([1, 2, 3], 1.0, [4, 5, 6]).is_ok()); + } + + #[test] + fn hot_jp_path_is_bit_identical_to_full_lcs_forward() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for byte in 0_u16..=255 { + let hex = format!("#{byte:02X}{byte:02X}{byte:02X}"); + let lean = jp_from_hex(&hex, &vc).unwrap(); + let full = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp; + assert_eq!(lean.to_bits(), full.to_bits(), "{hex}"); + } + for hex in ["#007AFF", "#FF3B30", "#34C759", "#FFD000", "#3E87FF"] { + let lean = jp_from_hex(hex, &vc).unwrap(); + let full = LcsColor::from_hex_with_vc(hex, &vc).unwrap().jp; + assert_eq!(lean.to_bits(), full.to_bits(), "{hex}"); + } + } + } + + /// Точка `x.5` принадлежит новому байту, а каждый state начинается ровно на + /// первом passing `f64` объявленного operation order. + #[test] + fn binary64_boundaries_follow_round_half_up_and_are_reproducible() { + let at_half = composite_hex(screen_layer_over_srgb8([1, 0, 0], 0.5, [0; 3]).unwrap()); + let just_below_half = f64::from_bits(0.5_f64.to_bits() - 1); + let below = + composite_hex(screen_layer_over_srgb8([1, 0, 0], just_below_half, [0; 3]).unwrap()); + assert_eq!(below, "#000000"); + assert_eq!(at_half, "#010000", "round(0.5) обязан выбрать верхний байт"); + + let states = quantised_composites([1, 0, 0], [0; 3]).unwrap(); + assert_eq!(states.len(), 2); + assert_eq!(states[0].bytes, [0; 3]); + assert_eq!(states[0].upper_bits, 0.5_f64.to_bits()); + assert!(!states[0].upper_inclusive); + assert_eq!(states[1].bytes, [1, 0, 0]); + assert_eq!(states[1].lower_bits, states[0].upper_bits); + assert!(states[1].upper_inclusive); + + let white_states = quantised_composites([255; 3], [0; 3]).unwrap(); + assert_eq!(white_states.len(), 256); + let first_boundary = white_states[0].upper_bits; + assert_eq!(white_states[1].lower_bits, first_boundary); + assert_eq!( + screen_layer_over_srgb8([255; 3], f64::from_bits(first_boundary - 1), [0; 3]).unwrap(), + [0; 3] + ); + assert_eq!( + screen_layer_over_srgb8([255; 3], f64::from_bits(first_boundary), [0; 3]).unwrap(), + [1; 3] + ); + assert_eq!(white_states[1].bytes, [1; 3]); + } + + #[test] + fn rationally_equal_walls_keep_distinct_binary64_seam_states() { + // Обе алгебраические стенки равны 255/508, но зафиксированный порядок + // binary64-операций переводит зелёный канал на 127 ULP раньше красного. + // Поэтому промежуточный RGB-state достижим и не может быть поглощён + // группировкой по равенству рациональных дробей. + let states = quantised_composites([1, 2, 0], [1, 128, 0]).unwrap(); + let seam_index = states + .iter() + .position(|state| state.bytes == [1, 129, 0]) + .expect("потерян достижимый binary64 seam-state #018100"); + let seam = states[seam_index]; + assert_eq!(seam.lower_bits, 4_602_696_549_879_841_156); + assert_eq!(seam.upper_bits, 4_602_696_549_879_841_283); + assert_eq!(seam.upper_bits - seam.lower_bits, 127); + assert_eq!(states[seam_index + 1].bytes, [2, 129, 0]); + } + + #[test] + fn rational_seed_is_a_stable_performance_hint_not_a_state_definition() { + for (glow, background, next_value, expected_bits) in [ + (1, 1, 2, 0x3fe0_1020_4081_0204), + (2, 128, 129, 0x3fe0_1020_4081_0204), + (33, 131, 132, 0x3f9f_e7f9_fe7f_9fe8), + (11, 0, 8, 0x3fe5_d174_5d17_45d1), + (255, 0, 255, 0x3fef_efef_efef_eff0), + ] { + assert_eq!( + boundary_seed_bits(glow, background, next_value), + expected_bits, + "glow={glow}, background={background}, next={next_value}", + ); + } + } + + #[test] + fn canonical_alpha_pins_the_midpoint_of_the_actual_binary64_partition() { + let state = quantised_composites([255, 59, 48], [0; 3]) + .unwrap() + .into_iter() + .find(|state| state.bytes == [3, 1, 1]) + .expect("reference state #030101"); + assert_eq!(state.lower_bits, 0x3f85_5555_5555_5555); + assert_eq!(state.upper_bits, 0x3f8c_1c1c_1c1c_1c1c); + assert_eq!(state.canonical_alpha().to_bits(), 0x3f88_b8b8_b8b8_b8b8); + + // Если representable state содержит только lower, численный midpoint + // half-ULP округляется к чётной исключённой upper boundary. Канон обязан + // вернуть её непосредственного predecessor, то есть единственный state. + let lower_bits = 0.5_f64.to_bits() + 1; + let singleton = QuantisedComposite { + bytes: [0; 3], + lower_bits, + upper_bits: lower_bits + 1, + upper_inclusive: false, + }; + assert_eq!(singleton.canonical_alpha().to_bits(), lower_bits); + } + + /// Каждый transition увеличивает хотя бы один из трёх байтов. Поэтому их + /// суммарно не больше `3·255`, а states — не больше 766. Fixtures включают + /// длинный поток и ULP-seam, который ломал прежнюю rational grouping. + #[test] + fn binary64_stream_respects_the_766_state_bound() { + for (glow, background) in [ + ([255, 255, 254], [0, 1, 1]), + ([1, 2, 0], [1, 128, 0]), + ([255; 3], [0; 3]), + ] { + let states = quantised_composites(glow, background).unwrap(); + assert!( + states.len() <= usize::from(MAX_QUANTISED_COMPOSITE_STATES), + "glow={glow:?}, background={background:?}, states={}", + states.len() + ); + for pair in states.windows(2) { + assert_eq!(pair[0].upper_bits, pair[1].lower_bits); + assert!(!pair[0].upper_inclusive); + assert!( + pair[1] + .bytes + .into_iter() + .zip(pair[0].bytes) + .all(|(next, previous)| next >= previous) + ); + } + assert!(states.last().unwrap().upper_inclusive); + } + + let solved = solve_legacy( + "#FFFFFE", + "#000101", + 101.0, + &ViewingConditions::dim_surround(), + ) + .unwrap(); + assert_eq!(solved.status(), GlowTargetStatus::LegacyUnreachable); + assert!(solved.achieved_dj() < 101.0); + } + + /// Известная десятичная alpha проверяется независимой целочисленной + /// формулой на всей одноканальной области. Это ловит нормализацию + /// `byte/255 → *255`, которая на `250 × 0.122` теряет точную половину. + #[test] + fn byte_reference_screen_alpha_0122_matches_exact_rational_for_all_channel_pairs() { + const ALPHA_NUMERATOR: u64 = 61; + const ALPHA_DENOMINATOR: u64 = 500; + const BYTE_MAX: u64 = 255; + let denominator = ALPHA_DENOMINATOR * BYTE_MAX; + + for glow in 0_u16..=255 { + for bg in 0_u16..=255 { + let glow = u64::from(glow); + let bg = u64::from(bg); + let numerator = bg * denominator + ALPHA_NUMERATOR * glow * (BYTE_MAX - bg); + let expected = ((2 * numerator + denominator) / (2 * denominator)) as u8; + let actual = screen_layer_over_srgb8([glow as u8, 0, 0], 0.122, [bg as u8, 0, 0]) + .unwrap()[0]; + assert_eq!(actual, expected, "glow={glow}, bg={bg}"); + } + } + + assert_eq!( + screen_layer_over_srgb8([192, 178, 250], 0.122, [0; 3]).unwrap(), + [23, 22, 31] + ); + let measurement = + measure_screen_layer_at_alpha("#C0B2FA", "#000000", 0.122, &ViewingConditions::srgb()) + .unwrap(); + assert_eq!(measurement.composite_hex, "#17161F"); + } + + /// На этой границе точное вещественное сравнение binary64 и фиксированный + /// порядок binary64-операций дают разные классификации. Reference намеренно + /// фиксирует второй вариант — тот же, который выполняет JS-потребитель; + /// солвер выбирает внутренние точки интервалов и перепроверяет результат. + #[test] + fn byte_screen_pins_reference_operation_order_at_float_wall() { + let rounded_rational_wall = 0.501_968_503_937_007_9_f64; + let first_passing = f64::from_bits(rounded_rational_wall.to_bits() - 1); + let below_first = f64::from_bits(first_passing.to_bits() - 1); + assert_eq!( + screen_layer_over_srgb8([1, 0, 0], below_first, [1, 0, 0]).unwrap()[0], + 1 + ); + assert_eq!( + screen_layer_over_srgb8([1, 0, 0], first_passing, [1, 0, 0]).unwrap()[0], + 2 + ); + assert_eq!( + screen_layer_over_srgb8([1, 0, 0], rounded_rational_wall, [1, 0, 0],).unwrap()[0], + 2 + ); + } + + /// Исчерпывающая теорема для одного канала: first-passing binary64 + /// partition совпадает с публичной формулой для всех 65 536 пар + /// `(background, glow)`. + #[test] + fn every_binary64_channel_partition_matches_the_public_compositor() { + for background in 0_u16..=255 { + for glow in 0_u16..=255 { + let b = background as u8; + let g = glow as u8; + let states = quantised_composites([g, 0, 0], [b, 0, 0]).unwrap(); + let final_byte = screen_layer_over_srgb8([g, 0, 0], 1.0, [b, 0, 0]).unwrap()[0]; + + assert_eq!(states.first().unwrap().bytes[0], b); + assert_eq!(states.last().unwrap().bytes[0], final_byte); + assert_eq!(states.len(), usize::from(final_byte - b) + 1); + + for (index, state) in states.iter().enumerate() { + assert!(state.lower_bits <= state.upper_bits); + if let Some(next) = states.get(index + 1) { + assert_eq!(state.upper_bits, next.lower_bits); + assert!(!state.upper_inclusive); + assert_eq!(next.bytes[0], state.bytes[0] + 1); + assert_eq!( + screen_layer_over_srgb8( + [g, 0, 0], + f64::from_bits(next.lower_bits), + [b, 0, 0], + ) + .unwrap()[0], + next.bytes[0], + "B={b}, G={g}, state={index}: lower boundary must pass", + ); + assert_eq!( + screen_layer_over_srgb8( + [g, 0, 0], + f64::from_bits(next.lower_bits - 1), + [b, 0, 0], + ) + .unwrap()[0], + state.bytes[0], + "B={b}, G={g}, state={index}: predecessor must fail", + ); + } + + let alpha = state.canonical_alpha(); + let out = screen_layer_over_srgb8([g, 0, 0], alpha, [b, 0, 0]).unwrap(); + assert_eq!( + out[0], state.bytes[0], + "B={b}, G={g}, state={index}, alpha={alpha}" + ); + } + } + } + } + /// Солвер достигает контрактной цели на тёмном фоне (среда свечения). #[test] fn solver_hits_targets_on_dark() { let vc = ViewingConditions::dim_surround(); for target in [GLOW_SUBTLE_DJ, GLOW_BASE_DJ, GLOW_BLOOM_DJ] { - let g = solve_screen_alpha_for_dj("#3E87FF", "#101012", target, &vc).unwrap(); - assert!(!g.degraded, "цель {target} недостижима на #101012"); - // Солвер возвращает ВЕРХНЮЮ сторону брекета: достигнутое ≥ цели - // (закон 2 ADR-0002 — ближайший достижимый СВЕРХУ), перелёт ограничен - // шагом 8-битной сетки на тёмной базе (≲0.3 J' на канал-инкремент). + let g = solve_legacy("#3E87FF", "#101012", target, &vc).unwrap(); + assert!(!g.degraded(), "цель {target} недостижима на #101012"); + assert_eq!(g.status(), GlowTargetStatus::LegacyReached); + assert_eq!( + g.selection_diagnostic_profile(), + Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1) + ); + // Первый достижимый sRGB8-композит обязан держать цель. Его + // минимальность независимо проверяется sweep-оракулом ниже. assert!( - g.achieved_dj >= target - 1e-9 && g.achieved_dj - target < 0.5, - "цель {target}: достигнуто {:.4} (ожидалось [цель, цель+0.5))", - g.achieved_dj + g.achieved_dj() >= target, + "цель {target}: достигнуто {:.4}", + g.achieved_dj() ); - assert!(g.alpha > 0.0 && g.alpha <= 1.0); + assert!(g.alpha() > 0.0 && g.alpha() <= 1.0); } } + /// Регрессия реальной границы квантования: альфа, округлённая отдельно от + /// рассчитанного hex, не имеет права незаметно вернуть предыдущий байт. + #[test] + fn serialised_alpha_keeps_the_quantised_target() { + let vc = ViewingConditions::dim_surround(); + let tint = encoded_bytes(srgb_encoded_from_hex("#4A8FFF").unwrap()); + let bg = encoded_bytes(srgb_encoded_from_hex("#101012").unwrap()); + let bg_jp = LcsColor::from_hex_with_vc("#101012", &vc).unwrap().jp; + let measured_at = |alpha: f64| { + let hex = composite_hex(screen_layer_over_srgb8(tint, alpha, bg).unwrap()); + let jp = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp; + ((jp - bg_jp).abs(), hex) + }; + + // Прежняя бисекция сходилась к рациональной стенке красного канала + // 1275/35372. Формат DTO с четырьмя знаками превращал её в 0.0360. + let legacy_boundary_alpha = 1_275.0 / 35_372.0; + let legacy_css = format!("{legacy_boundary_alpha:.4}"); + assert_eq!(legacy_css, "0.0360"); + let (rounded_dj, rounded_hex) = measured_at(legacy_css.parse().unwrap()); + assert_eq!(rounded_hex, "#12151B"); + assert!( + rounded_dj < GLOW_BASE_DJ, + "контрпример обязан лежать ниже цели: {rounded_dj}" + ); + + let solved = solve_legacy("#4A8FFF", "#101012", GLOW_BASE_DJ, &vc).unwrap(); + let alpha_css = solved.alpha_css(); + let serialised = alpha_css.parse::().unwrap(); + assert_eq!(serialised.to_bits(), solved.alpha().to_bits()); + let (serialised_dj, serialised_hex) = measured_at(serialised); + assert_eq!(serialised_hex, solved.composite_hex()); + assert!( + serialised_dj >= GLOW_BASE_DJ, + "эмитируемая alpha={alpha_css} дала {serialised_hex} с ΔJ'={serialised_dj}, ниже цели {}", + GLOW_BASE_DJ + ); + } + + /// Матрица свойств сверяет минимальный успешный state и глобальный + /// максимум при деградации с независимым оракулом. В ней есть нулевой тинт, + /// белый фон и разные surround — ветви, где предположение `alpha=1` особенно + /// легко скрывает ошибку выбора. + #[test] + fn solver_matches_independent_finite_state_oracle() { + let vcs = [ViewingConditions::srgb(), ViewingConditions::dim_surround()]; + for tint_hex in ["#000000", "#010200", "#4A8FFF", "#FF3B30", "#FFFFFF"] { + for bg_hex in ["#000000", "#018000", "#101012", "#808080", "#FFFFFF"] { + for vc in vcs { + let oracle = oracle_states(tint_hex, bg_hex, &vc); + assert!(!oracle.is_empty()); + let mut global_best = &oracle[0]; + for state in &oracle[1..] { + if state.2 > global_best.2 { + global_best = state; + } + } + + for target in [0.01, 0.32, 1.0, 10.0, global_best.2 + 1.0] { + let solved = solve_legacy(tint_hex, bg_hex, target, &vc).unwrap(); + let expected = oracle.iter().find(|state| state.2 >= target); + let (expected_state, degraded) = match expected { + Some(state) => (state, false), + None => (global_best, true), + }; + assert_eq!( + solved.composite_hex(), + expected_state.1, + "tint={tint_hex}, bg={bg_hex}, target={target}" + ); + assert_eq!(solved.achieved_dj().to_bits(), expected_state.2.to_bits()); + assert_eq!(solved.degraded(), degraded); + + let emitted_alpha = solved.alpha_css().parse::().unwrap(); + assert_eq!( + emitted_alpha.to_bits(), + solved.alpha().to_bits(), + "CSS round-trip обязан сохранять binary64: tint={tint_hex}, bg={bg_hex}, target={target}" + ); + let recomposed = composite_hex( + screen_layer_over_srgb8( + encoded_bytes(srgb_encoded_from_hex(tint_hex).unwrap()), + emitted_alpha, + encoded_bytes(srgb_encoded_from_hex(bg_hex).unwrap()), + ) + .unwrap(), + ); + assert_eq!(recomposed, solved.composite_hex()); + + let certificate = solved.composite_certificate(); + assert_eq!(certificate.profile().key(), GLOW_COMPOSITE_PROFILE); + assert_eq!(certificate.guarantee(), GlowCompositeGuaranteeV1::BitExact); + assert_eq!( + certificate.tint_srgb8(), + encoded_bytes(srgb_encoded_from_hex(tint_hex).unwrap()) + ); + assert_eq!( + certificate.background_srgb8(), + encoded_bytes(srgb_encoded_from_hex(bg_hex).unwrap()) + ); + assert_eq!(certificate.alpha_bits(), emitted_alpha.to_bits()); + assert_eq!(certificate.alpha_css(), solved.alpha_css()); + assert_eq!( + composite_hex(certificate.composite_srgb8()), + solved.composite_hex() + ); + } + } + } + } + } + + #[test] + fn solver_rejects_non_finite_and_non_positive_targets() { + let vc = ViewingConditions::dim_surround(); + for target in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0] { + assert!(solve_legacy("#4A8FFF", "#101012", target, &vc).is_err()); + assert!( + solve_screen_alpha_for_dj( + "#4A8FFF", + "#101012", + target, + GlowDecisionProfileV1::StableV1, + &vc, + ) + .is_err(), + "stable profile accepted invalid target {target}" + ); + } + + let mut invalid_vc = vc; + invalid_vc.n = f64::NAN; + assert!(solve_legacy("#4A8FFF", "#101012", GLOW_BASE_DJ, &invalid_vc).is_err()); + } + /// Ступени контракта строго возрастают и по цели, и по решённой α. #[test] fn glow_stack_is_strictly_progressive() { @@ -270,31 +1823,36 @@ mod tests { let vc = ViewingConditions::dim_surround(); let mut prev = 0.0; for target in [GLOW_SUBTLE_DJ, GLOW_BASE_DJ, GLOW_BLOOM_DJ] { - let g = solve_screen_alpha_for_dj("#FF3B30", "#101012", target, &vc).unwrap(); + let g = solve_legacy("#FF3B30", "#101012", target, &vc).unwrap(); assert!( - g.alpha > prev, + g.alpha() > prev, "α стека не прогрессивна: {} после {prev}", - g.alpha + g.alpha() ); - prev = g.alpha; + prev = g.alpha(); } } - /// На белом фоне свечение честно деградирует с флагом (физика screen), - /// а не молчит и не ошибается — ADR-0002, закон 2. + /// На белом фоне screen-слой является point-no-op по объявленной формуле; + /// недостижимость поэтому должна быть явной, а не замаскированной ошибкой. #[test] fn glow_on_white_degrades_honestly() { let vc = ViewingConditions::srgb(); - let g = solve_screen_alpha_for_dj("#3E87FF", "#FFFFFF", GLOW_BASE_DJ, &vc).unwrap(); - assert!(g.degraded, "на белом screen обязан гаснуть"); - assert!(g.achieved_dj < GLOW_BASE_DJ); - assert_eq!(g.composite_hex, "#FFFFFF", "screen над белым — тождество"); + let g = solve_legacy("#3E87FF", "#FFFFFF", GLOW_BASE_DJ, &vc).unwrap(); + assert!(g.degraded(), "над белым screen обязан быть point-no-op"); + assert_eq!(g.status(), GlowTargetStatus::LegacyUnreachable); + assert!(g.achieved_dj() < GLOW_BASE_DJ); + assert_eq!(g.composite_hex(), "#FFFFFF", "screen над белым — тождество"); + assert_eq!( + g.alpha(), + 0.5, + "единственный state канонизируется центром [0,1]" + ); } - /// Анатомия core по ЗАКОНУ БАЛАНСА: светлее источника (пересвет), оттенок - /// унаследован, цвет центра ВЗЯТ ИЗ примитива баланса (не своя фикс-дельта), - /// насыщенный источник не вырождается, и хрома центра ПО ПОСТРОЕНИЮ в гамуте - /// (стена гамута — без тихого клипа, класс бага прежней ×0.5-доли). + /// Recipe v1: core светлее источника, использует его Oklab hue и + /// gamut-boundary chroma без тихого клипа. Тест фиксирует алгоритм, а не + /// проверенный на наблюдателях закон оптимального свечения. #[test] fn core_is_balanced_overexposed_source() { let vc = ViewingConditions::dim_surround(); @@ -306,19 +1864,6 @@ mod tests { let dh = (core.h_ok - src.h_ok + 180.0).rem_euclid(360.0) - 180.0; assert!(dh.abs() < 6.0, "оттенок унаследован: Δh = {dh:.2}°"); - // Центр ВЗЯТ из примитива баланса — доказательство wiring'а. - let l_core = crate::scale::jp_to_oklab_l((src.jp + 100.0) * 0.5, &vc); - let balanced = crate::accent_balance::accent_balanced(l_core, src.h_ok, &vc); - assert_eq!( - core_hex, - balanced.color.to_hex_with_vc(&vc), - "центр glow обязан быть цветом примитива баланса" - ); - assert!( - !balanced.hue_vanished, - "насыщенный источник не вырождается в центре" - ); - // Хрома баланса = стена гамута ⇒ эмиссия в гамуте, без тихого клипа // (прежняя ×0.5-доля могла запросить недостижимую красочность и молча // срезаться в to_hex). Round-trip красочности центра стабилен. @@ -328,4 +1873,40 @@ mod tests { "центр в гамуте: round-trip красочности стабилен (без тихого клипа)" ); } + + #[test] + fn layer_recipe_canonicalises_the_public_source_hex() { + let vc = ViewingConditions::srgb(); + let (core_hex, halo_hex) = glow_layers_from_source("ff3b30", &vc).unwrap(); + assert_eq!(halo_hex, "#FF3B30"); + assert!(core_hex.starts_with('#')); + assert_eq!(core_hex.len(), 7); + } + + /// У sRGB8-серого нет определённого hue. Матричный шум Oklab не должен + /// превращаться в насыщенный core ни на одном байте и ни в одной штатной VC. + #[test] + fn exact_achromatic_sources_never_receive_an_invented_core_hue() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for byte in u8::MIN..=u8::MAX { + let source = format!("#{byte:02X}{byte:02X}{byte:02X}"); + let (core, halo) = glow_layers_from_source(&source, &vc).unwrap(); + let [r, g, b] = encoded_bytes(srgb_encoded_from_hex(&core).unwrap()); + assert_eq!(halo, source); + assert_eq!(r, g, "{source} дал цветной core {core}"); + assert_eq!(g, b, "{source} дал цветной core {core}"); + } + } + } + + /// Публичные поля VC позволяют внешнему коду создать несогласованное + /// производное состояние. Такое состояние раньше протаскивало NaN через + /// CAM16 и маскировало его квантизацией в правдоподобный `#000000`. + #[test] + fn layer_recipe_rejects_numerically_degenerate_viewing_conditions() { + let mut vc = ViewingConditions::srgb(); + vc.fl = 1.0e308; + vc.fl_pow_025 = 1.0e308; + assert!(glow_layers_from_source("#4A8FFF", &vc).is_err()); + } } diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 523373be..e652b40c 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod lcs; pub mod lpc; pub mod material; pub mod neutral; +pub mod numerics; pub mod pair; pub mod scale; pub mod semantic; @@ -83,19 +84,31 @@ pub use config::{ }; pub use curve::ColorCurve; pub use glow::{ - GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_SUBTLE_DJ, GlowSolve, glow_layers_from_source, - screen_layer_over_encoded, solve_screen_alpha_for_dj, + GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_COMPOSITE_PROFILE, GLOW_DIAGNOSTIC_PROFILE, + GLOW_LAYER_RECIPE_PROFILE, GLOW_SUBTLE_DJ, GlowCompositeCertificateV1, + GlowCompositeGuaranteeV1, GlowCompositeProfileV1, GlowConstraintLayer, GlowDecisionProfileV1, + GlowDiagnosticProfileV1, GlowLayerRecipeProfileV1, GlowSolve, GlowTargetStatus, + glow_layers_from_source, screen_layer_over_encoded, screen_layer_over_srgb8, + screen_point_is_exact_noop, solve_screen_alpha_for_dj, }; pub use hash::fnv1a_32; pub use ladder::{LadderPosition, LadderTint, ThemeAnchors}; pub use lcs::LcsColor; pub use material::{ - BackdropBox, MaterialAlpha, Pole, committed_pole_encoded, solve_material_alpha_encoded, + BackdropBoundV1, BackdropBox, BackdropBoxErrorV1, EncodedRgbErrorV1, MaterialAlpha, + MaterialAlphaGuaranteeV1, MaterialAlphaStatusV1, MaterialNumericalProfileV1, + MaterialSolveErrorV1, Pole, RgbChannelV1, committed_pole_encoded, solve_material_alpha_encoded, solve_material_alpha_hex, worst_contrast_encoded, }; +pub use numerics::{ + AtLeastDecisionV1, DecisionGuaranteeV1, NumericalBoundStatusV1, NumericalDecisionV1, + NumericalFallbackStatusV1, NumericalIndeterminacyV1, NumericalSiteIdV1, NumericalSiteRecordV1, + OutwardIntervalV1, StableNumericalOutcomeV1, classify_at_least_v1, numerical_registry_v1, +}; pub use semantic::{ - NamedRoleTable, Resolved, RoleChroma, RoleSpec, TextAnchor, TranslucentResolved, - measure_contrast, recheck_against, recheck_against_multi, resolve_named_set, + GlowIndeterminateResolved, NamedRoleTable, Resolved, RoleChroma, RoleSpec, TextAnchor, + TranslucentResolved, measure_contrast, recheck_against, recheck_against_multi, + resolve_named_set, }; // The built-in v1 showcase (`Role`/`RoleTable`/`resolve`/`resolve_set`) is no // longer part of the production API (ADR-0001 PR-c): the agnostic engine ships @@ -108,14 +121,14 @@ pub use solve::{ BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveJob, Solved, TypographicContext, Unreachable, solve, solve_many, }; -pub use spaces::oklch::{oklch_css_from_hex, oklch_from_hex}; +pub use spaces::oklch::{css_alpha_value, oklch_css_from_hex, oklch_from_hex}; pub use spaces::p3::{p3_css_from_hex, p3_from_hex}; pub use spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; pub use spaces::vc::ViewingConditions; -/// Компилирует rust-блоки корневого README как doctest-ы: их API-примеры -/// обязаны собираться на каждом `cargo test --doc`, иначе README тихо -/// разъедется с кодом. Тип существует только под `--test`, в бинарь не входит. +/// Компилирует rust-блоки package-local README как doctest-ы: опубликованный +/// crate обязан нести и исполнять собственную документацию без файлов выше +/// package root. Тип существует только под `--test`, в бинарь не входит. #[cfg(doctest)] -#[doc = include_str!("../../../README.md")] +#[doc = include_str!("../README.md")] pub struct ReadmeDoctests; diff --git a/crates/labcolors-core/src/lpc.rs b/crates/labcolors-core/src/lpc.rs index 8e294b14..4bef63df 100644 --- a/crates/labcolors-core/src/lpc.rs +++ b/crates/labcolors-core/src/lpc.rs @@ -242,6 +242,9 @@ pub(crate) const LC_SCALE: f64 = 100.0; /// решатель, целясь в декоративный контраст ниже 7.3, упирается в порог клипа и /// возвращает ноль, поэтому `DECORATIVE_FLOOR_MIN` держится строго выше него. /// Скан-инвариант — `no_pair_emits_contrast_below_model_floor`. +// Rust 1.85 не считает использование только в const-assert/test runtime-use; +// константа намеренно остаётся исполняемым provenance lock, не shipping knob. +#[allow(dead_code)] // SSOT-TRACKED — (a) DERIVED = (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE (issue #44), см. docs/empirical-inventory.md. pub(crate) const MODEL_LC_FLOOR: f64 = 7.3; diff --git a/crates/labcolors-core/src/material.rs b/crates/labcolors-core/src/material.rs index bf37667c..51750146 100644 --- a/crates/labcolors-core/src/material.rs +++ b/crates/labcolors-core/src/material.rs @@ -13,22 +13,28 @@ //! //! # Выведенная альфа (не рукописная) //! -//! `α` — минимальная плотность, при которой тинт над ХУДШИМ разрешённым фоном -//! остаётся в контракте базы: коммит-лейбл поверхности (ахроматический полюс +//! `α` — проходящая плотность, выбранная бисекцией с фиксированным числом шагов +//! в объявленном охарактеризованном для платформы runtime: тинт над ХУДШИМ +//! разрешённым фоном остаётся в +//! контракте базы. Коммит-лейбл поверхности (ахроматический полюс //! максимального контраста на тоне `T` — белый на тёмном `T`, чёрный на светлом) //! держит пол читаемости ПО ВСЕМУ коридору достижимых фонов. //! -//! Композит `α·T + (1−α)·B` афинно-возрастает по каждому каналу фона `B`, а -//! WCAG-светлота монотонно растёт по каждому каналу композита — поэтому по -//! осепараллельному коробу-коридору `[min, max]` светлота композита экстремальна -//! РОВНО на углах `min`/`max` (доказано `band_luminance`+property-тестом). Худший -//! контраст полюса берётся по этой достижимой полосе `[effLow, effHigh]`: -//! эквивалент α-граничной гарантии потребителя (`material-guarantee.ts`), но -//! ИНВЕРТИРОВАННОЙ — не «годен/негоден при данной α», а «минимальная годная α». +//! Композит исполняется в официальном byte-scale affine order +//! `(B_byte + α·(T_byte−B_byte))/255`. Алгебраическая монотонность этой формулы +//! недостаточна для binary64: округление может опустить interior background на +//! один ULP ниже обоих endpoint-значений. Кроме того, frozen legacy WCAG 2.1 +//! (2018) split `0.03928` имеет малый downward seam. Поэтому ядро строит +//! поканальный conservative envelope фактического композитора, включает обе +//! стороны пересечённого EOTF seam и только затем складывает положительно +//! взвешенные channel ranges. Это all-backdrop characterization выбранного +//! state, а не ложная теорема о двух углах. //! -//! Худший контраст монотонно растёт по `α` (полоса стягивается к `T`, полюс на -//! верной стороне), поэтому солвер — бисекция, как остальные полы движка -//! (`glow::solve_screen_alpha_for_dj`). На `α = 1` полоса вырождается в `L(T)`, а +//! На поддерживаемом направленном домене material-солвер применяет детерминированную +//! бисекцию с фиксированным числом шагов и повторно проверяет обе стороны +//! найденного fail/pass bracket. Legacy EOTF seam опровергает глобальную +//! монотонность, поэтому результат не называется первым или минимальным. На +//! `α = 1` полоса вырождается в `L(T)`, а //! полюс максимального контраста на ЛЮБОМ тоне даёт ≥ 4.58:1 (кроссовер чёрного и //! белого полюса при `L ≈ 0.179`), поэтому для пола ≤ AA годная `α ∈ (0, 1]` //! существует всегда; при более высоком поле (напр. AAA на среднем тоне) @@ -36,15 +42,43 @@ //! //! # Пространство //! -//! Композит — гамма-кодированный sRGB (device-пространство браузера/Figma), то же -//! измеренное пространство, что у [`crate::alpha`] (12 Figma-пар roundtrip). WCAG- +//! Композит — гамма-кодированный sRGB reference-профиля [`crate::alpha`], +//! заземлённого 12 Figma-парами, но не выдаваемого за любой браузерный pipeline. WCAG- //! светлота меряется на кодированном тоне-тинте (квантованном до 8-битного hex — -//! эмитируемый цвет `01`), композит над фоном берётся ТОЧНО (без переквантования): -//! ровно так потребитель пересчитывает вердикт из эмитированных `01`/`02`, поэтому -//! гарантия ядра и re-check потребителя тождественны. +//! эмитируемый цвет `01`), композит над фоном берётся без промежуточного +//! переквантования как `(B_byte + α·(T_byte−B_byte))/255`: тем же byte-scale +//! affine порядком binary64-операций, что официальный потребитель. Потребитель +//! может независимо воспроизвести point-композиты; all-backdrop verdict ядра +//! дополнительно использует описанный conservative channel/EOTF envelope и не +//! равен одному raw point-замеру. Применимость к рендереру и управлению цветом +//! принадлежит отдельному conformance-гейту. use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; -use crate::wcag::{ratio_from_luminances, relative_luminance}; +use crate::wcag::{ratio_from_luminances, relative_luminance, relative_luminance_range}; +use std::fmt; + +const MATERIAL_ALPHA_BISECTION_ITERATIONS: u32 = 60; + +/// Absolute encoded-channel padding around the two actual endpoint evaluations +/// of the byte-scale affine compositor. +/// +/// With all numerator-scale operands bounded by 255, a binary64 forward-error +/// accounting bounds one evaluation by +/// `E = 5·2^-46/255 + 2^-53 < 3.90e-16`. Comparing an interior evaluation with +/// an evaluated endpoint therefore costs `2E`; the outward padding operation +/// can lose at most another `2^-53` to rounding. The combined requirement is +/// `< 8.91e-16`. `8·EPSILON = 2^-49` is exactly `816/409 ≈ 1.995` times that +/// requirement while remaining an absolute bound near zero. This covers +/// compositor rounding only; WCAG `powf` remains platform-characterized. +#[cfg(test)] +const MATERIAL_COMPOSITE_SINGLE_EVALUATION_ERROR_BOUND: f64 = + 5.0 * (64.0 * f64::EPSILON) / 255.0 + 0.5 * f64::EPSILON; +#[cfg(test)] +const MATERIAL_COMPOSITE_PAIRWISE_ERROR_BOUND: f64 = + 2.0 * MATERIAL_COMPOSITE_SINGLE_EVALUATION_ERROR_BOUND; +#[cfg(test)] +const MATERIAL_COMPOSITE_OUTWARD_ROUNDING_BOUND: f64 = 0.5 * f64::EPSILON; +const MATERIAL_COMPOSITE_RANGE_MARGIN: f64 = 8.0 * f64::EPSILON; /// Ахроматический полюс коммит-лейбла поверхности: цвет максимального контраста /// на тоне (белый на тёмном тоне, чёрный на светлом). Механизм полярности @@ -59,6 +93,7 @@ pub enum Pole { impl Pole { /// WCAG-светлота полюса: `0.0` (чёрный) / `1.0` (белый). + #[cfg(test)] fn luminance(self) -> f64 { match self { Pole::Black => 0.0, @@ -67,18 +102,351 @@ impl Pole { } } +/// Канал encoded-sRGB в типизированных ошибках валидации. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RgbChannelV1 { + /// Красный канал. + Red, + /// Зелёный канал. + Green, + /// Синий канал. + Blue, +} + +impl RgbChannelV1 { + fn from_index(index: usize) -> Self { + match index { + 0 => Self::Red, + 1 => Self::Green, + 2 => Self::Blue, + _ => unreachable!("RGB channel index is internal and bounded by 0..3"), + } + } + + /// Стабильный машинный ключ. + pub fn key(self) -> &'static str { + match self { + Self::Red => "red", + Self::Green => "green", + Self::Blue => "blue", + } + } +} + +/// Какая граница [`BackdropBox`] нарушила инвариант encoded-sRGB. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BackdropBoundV1 { + /// Поканальный минимум. + Min, + /// Поканальный максимум. + Max, +} + +impl BackdropBoundV1 { + /// Стабильный машинный ключ. + pub fn key(self) -> &'static str { + match self { + Self::Min => "min", + Self::Max => "max", + } + } +} + +/// Нарушение домена encoded-sRGB `[0,1]^3`. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum EncodedRgbErrorV1 { + /// Канал равен NaN или бесконечности. + NonFiniteChannel { + /// Канал, нарушивший конечный домен. + channel: RgbChannelV1, + }, + /// Конечный канал лежит вне `[0,1]`. + OutOfRangeChannel { + /// Канал, нарушивший диапазон. + channel: RgbChannelV1, + /// Фактическое значение. + value: f64, + }, +} + +impl fmt::Display for EncodedRgbErrorV1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonFiniteChannel { channel } => { + write!(f, "encoded-sRGB {} channel is not finite", channel.key()) + } + Self::OutOfRangeChannel { channel, value } => write!( + f, + "encoded-sRGB {} channel is outside [0,1]: {value}", + channel.key() + ), + } + } +} + +impl std::error::Error for EncodedRgbErrorV1 {} + +/// Типизированное нарушение инварианта при создании [`BackdropBox`]. +// Имена публичных вариантов явно несут нарушенный инвариант канала; удаление +// общего суффикса сделало бы сопоставление с образцом менее самодокументируемым. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum BackdropBoxErrorV1 { + /// Канал одной из границ равен NaN или бесконечности. + NonFiniteChannel { + /// Нарушившая граница. + bound: BackdropBoundV1, + /// Нарушивший канал. + channel: RgbChannelV1, + }, + /// Конечный канал одной из границ лежит вне `[0,1]`. + OutOfRangeChannel { + /// Нарушившая граница. + bound: BackdropBoundV1, + /// Нарушивший канал. + channel: RgbChannelV1, + /// Фактическое значение. + value: f64, + }, + /// `min[channel] > max[channel]`; конструктор не переставляет границы молча. + ReversedChannel { + /// Перепутанный канал. + channel: RgbChannelV1, + /// Переданный минимум. + min: f64, + /// Переданный максимум. + max: f64, + }, +} + +impl fmt::Display for BackdropBoxErrorV1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonFiniteChannel { bound, channel } => write!( + f, + "backdrop {} {} channel is not finite", + bound.key(), + channel.key() + ), + Self::OutOfRangeChannel { + bound, + channel, + value, + } => write!( + f, + "backdrop {} {} channel is outside [0,1]: {value}", + bound.key(), + channel.key() + ), + Self::ReversedChannel { channel, min, max } => write!( + f, + "backdrop {} channel is reversed: min={min}, max={max}", + channel.key() + ), + } + } +} + +impl std::error::Error for BackdropBoxErrorV1 {} + +/// Класс численного профиля, в котором охарактеризована граница material-alpha. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MaterialNumericalProfileV1 { + /// Byte-scale affine source-over и frozen legacy WCAG 2.1 (2018) + /// `0.03928`/`powf` в Rust IEEE-754 binary64; `powf` остаётся зависимым от + /// платформы и toolchain. Canonical WCAG 2.2 принадлежит issue #284. + EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, +} + +impl MaterialNumericalProfileV1 { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1 => { + "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" + } + } + } +} + +/// Честный класс численной гарантии выбранной material alpha. +// Суффикс — часть публичного словаря гарантий: каждый текущий вариант является +// явно охарактеризованным исходом, но не доказательством точности или минимальности. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum MaterialAlphaGuaranteeV1 { + /// Детерминированная бинарная partition с фиксированным числом шагов; + /// выбранный верхний кандидат повторно прошёл floor, а нижний повторно не + /// прошёл. Глобальная монотонность, первый passing state и sound- + /// доказательство межсредовой минимальности не заявляются. + BisectionBracketCharacterizedV1 { + /// Число исполненных шагов бисекции. + iterations: u32, + /// Последний кандидат, измеренный ниже floor в этом профиле. + lower_alpha: f64, + /// Верхняя граница интервала кандидатов, повторно прошедшая floor. + upper_alpha: f64, + /// Численный профиль чувствительного к ветвлению WCAG-пути `powf`. + numerical_profile: MaterialNumericalProfileV1, + }, + /// Уже прозрачный endpoint держит floor; бисекция не нужна. + TransparentEndpointCharacterizedV1 { + /// Численный профиль повторной проверки возвращённого худшего контраста. + numerical_profile: MaterialNumericalProfileV1, + }, + /// Даже непрозрачный endpoint не держит floor; endpoint повторно измерен + /// в указанном профиле и возвращён как ближайшее достижимое состояние. + OpaqueEndpointCharacterizedV1 { + /// Численный профиль чувствительного к ветвлению WCAG-пути `powf`. + numerical_profile: MaterialNumericalProfileV1, + }, +} + +impl MaterialAlphaGuaranteeV1 { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::BisectionBracketCharacterizedV1 { .. } => "bisection-bracket-characterized-v1", + Self::TransparentEndpointCharacterizedV1 { .. } => { + "transparent-endpoint-characterized-v1" + } + Self::OpaqueEndpointCharacterizedV1 { .. } => "opaque-endpoint-characterized-v1", + } + } + + /// Runtime-класс, неразделимо связанный с гарантией. + pub fn numerical_profile(self) -> MaterialNumericalProfileV1 { + match self { + Self::BisectionBracketCharacterizedV1 { + numerical_profile, .. + } + | Self::TransparentEndpointCharacterizedV1 { numerical_profile } + | Self::OpaqueEndpointCharacterizedV1 { numerical_profile } => numerical_profile, + } + } + + /// Фиксированное число шагов бинарной partition для bracket-исхода. + pub fn iterations(self) -> Option { + match self { + Self::BisectionBracketCharacterizedV1 { iterations, .. } => Some(iterations), + Self::TransparentEndpointCharacterizedV1 { .. } + | Self::OpaqueEndpointCharacterizedV1 { .. } => None, + } + } + + /// Охарактеризованные нижний и верхний кандидаты только для настоящего + /// порогового интервала. + pub fn bracket(self) -> Option<(f64, f64)> { + match self { + Self::BisectionBracketCharacterizedV1 { + lower_alpha, + upper_alpha, + .. + } => Some((lower_alpha, upper_alpha)), + Self::TransparentEndpointCharacterizedV1 { .. } + | Self::OpaqueEndpointCharacterizedV1 { .. } => None, + } + } +} + +/// Доменный исход решения material-alpha; недостижимость не является ошибкой. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MaterialAlphaStatusV1 { + /// Выбранная alpha проходит запрошенный floor в объявленном runtime. + Satisfied, + /// Floor недостижим даже при alpha 1; возвращается непрозрачный endpoint. + Degraded, +} + +impl MaterialAlphaStatusV1 { + /// Стабильный wire-ключ. + pub fn key(self) -> &'static str { + match self { + Self::Satisfied => "satisfied", + Self::Degraded => "degraded", + } + } +} + +/// Типизированная публичная ошибка material-вычислений; без clamp, swap и fallback. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum MaterialSolveErrorV1 { + /// Тон нарушает домен encoded-sRGB `[0,1]^3`. + InvalidTone(EncodedRgbErrorV1), + /// Hex-обёртка получила неканоническую или невалидную строку sRGB. + InvalidToneHex { + /// Свидетельство парсера. + reason: String, + }, + /// Alpha не конечна или лежит вне `[0,1]`. + InvalidAlpha { + /// Отклонённая alpha. + value: f64, + }, + /// WCAG floor не конечен или лежит вне `[1,21]`. + InvalidFloorRatio { + /// Отклонённое отношение. + value: f64, + }, + /// Текущий закон бисекции неприменим к этому валидному отношению тона и фона. + UnsupportedDirectedSearchRelation { + /// Коммит-полюс, направленное предусловие которого нарушено. + pole: Pole, + }, + /// Предполагавшийся конечным внутренний численный результат вышел из + /// объявленного домена. + NumericallyIndeterminate, +} + +impl fmt::Display for MaterialSolveErrorV1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTone(error) => write!(f, "invalid material tone: {error}"), + Self::InvalidToneHex { reason } => write!(f, "invalid material tone hex: {reason}"), + Self::InvalidAlpha { value } => { + write!(f, "material alpha is outside finite [0,1]: {value}") + } + Self::InvalidFloorRatio { value } => { + write!(f, "material floor ratio is outside finite [1,21]: {value}") + } + Self::UnsupportedDirectedSearchRelation { pole } => write!( + f, + "material tone/backdrop relation does not support directed search for {pole:?} pole" + ), + Self::NumericallyIndeterminate => { + f.write_str("material numerical result left its declared finite domain") + } + } + } +} + +impl std::error::Error for MaterialSolveErrorV1 {} + /// Осепараллельный короб достижимых фонов: поканальный минимум и максимум. /// /// `min`/`max` — кодированные углы `[0,1]³`. Материальный дефолт (стекло над /// неизвестным живым фоном) — [`FULL`](Self::FULL) = `[чёрный, белый]`, худший /// возможный коридор. Известная область (изображение/градиент под лейблом) — её /// поканальные экстремумы (обобщение коридора, labui ADR-0004 Решение 2). +/// Прямые литералы структуры намеренно недоступны; все публичные экземпляры +/// проходят через [`try_new`](Self::try_new). +/// +/// ```compile_fail +/// use labcolors_core::BackdropBox; +/// let _ = BackdropBox { min: [0.0; 3], max: [1.0; 3] }; +/// ``` #[derive(Debug, Clone, Copy, PartialEq)] pub struct BackdropBox { - /// Поканально-минимальный (темнейший) угол фона. - pub min: [f64; 3], - /// Поканально-максимальный (светлейший) угол фона. - pub max: [f64; 3], + min: [f64; 3], + max: [f64; 3], } impl BackdropBox { @@ -88,28 +456,108 @@ impl BackdropBox { min: [0.0, 0.0, 0.0], max: [1.0, 1.0, 1.0], }; + + /// Создаёт короб только из конечных границ encoded-sRGB с `min <= max`. + /// Перепутанные границы отвергаются, а не нормализуются молча. + pub fn try_new(min: [f64; 3], max: [f64; 3]) -> Result { + validate_backdrop_bound(BackdropBoundV1::Min, min)?; + validate_backdrop_bound(BackdropBoundV1::Max, max)?; + for channel_index in 0..3 { + if min[channel_index] > max[channel_index] { + return Err(BackdropBoxErrorV1::ReversedChannel { + channel: RgbChannelV1::from_index(channel_index), + min: min[channel_index], + max: max[channel_index], + }); + } + } + Ok(Self { min, max }) + } + + /// Поканально-минимальный (темнейший) угол. + pub fn min(self) -> [f64; 3] { + self.min + } + + /// Поканально-максимальный (светлейший) угол. + pub fn max(self) -> [f64; 3] { + self.max + } } /// Результат вывода альфы материала: плотность + вердикт гарантии. #[derive(Debug, Clone, Copy, PartialEq)] pub struct MaterialAlpha { - /// Выведенная альфа тинта `01`, `(0, 1]`. - pub alpha: f64, - /// Худший WCAG-контраст коммит-полюса по коридору при выведенной `alpha` - /// (`[1, 21]`). При не-деградации ≥ запрошенного пола. - pub worst_contrast: f64, - /// Коммит-полюс поверхности (полярность лейбла тона). - pub pole: Pole, - /// Даже при `α = 1` худший контраст ниже пола (напр. AAA-пол на среднем - /// тоне): возвращена `α = 1` как ближайшая достижимая, гарантия НЕ выполнена. - /// Честный флаг деградации контракта (закон 2 ADR-0002), не молчание. - pub degraded: bool, + alpha: f64, + worst_contrast: f64, + pole: Pole, + status: MaterialAlphaStatusV1, + guarantee: MaterialAlphaGuaranteeV1, } -/// Валидный кодированный цвет: конечный и в `[0,1]³`. -fn is_encoded_rgb(v: [f64; 3]) -> bool { - v.into_iter() - .all(|x| x.is_finite() && (0.0..=1.0).contains(&x)) +impl MaterialAlpha { + /// Выбранная alpha тинта `01`, `[0,1]`. + pub fn alpha(self) -> f64 { + self.alpha + } + + /// Повторно вычисленный худший контраст выбранного состояния. + pub fn worst_contrast(self) -> f64 { + self.worst_contrast + } + + /// Коммит-полюс поверхности. + pub fn pole(self) -> Pole { + self.pole + } + + /// Доменный исход satisfied/degraded. + pub fn status(self) -> MaterialAlphaStatusV1 { + self.status + } + + /// Класс численного свидетельства; не смешивается с утверждением точности + /// композитора. + pub fn guarantee(self) -> MaterialAlphaGuaranteeV1 { + self.guarantee + } + + /// Предикат совместимости поверх типизированного статуса. + pub fn degraded(self) -> bool { + self.status == MaterialAlphaStatusV1::Degraded + } +} + +/// Единственный валидатор массива encoded-sRGB: конечность и `[0,1]^3`. +fn validate_encoded_rgb(v: [f64; 3]) -> Result<(), EncodedRgbErrorV1> { + for (channel_index, value) in v.into_iter().enumerate() { + let channel = RgbChannelV1::from_index(channel_index); + if !value.is_finite() { + return Err(EncodedRgbErrorV1::NonFiniteChannel { channel }); + } + if !(0.0..=1.0).contains(&value) { + return Err(EncodedRgbErrorV1::OutOfRangeChannel { channel, value }); + } + } + Ok(()) +} + +fn validate_backdrop_bound( + bound: BackdropBoundV1, + value: [f64; 3], +) -> Result<(), BackdropBoxErrorV1> { + validate_encoded_rgb(value).map_err(|error| match error { + EncodedRgbErrorV1::NonFiniteChannel { channel } => { + BackdropBoxErrorV1::NonFiniteChannel { bound, channel } + } + EncodedRgbErrorV1::OutOfRangeChannel { channel, value } => { + BackdropBoxErrorV1::OutOfRangeChannel { + bound, + channel, + value, + } + } + }) } /// Квантовать кодированный цвет до 8-битной сетки дисплея (round-trip через hex — @@ -123,161 +571,257 @@ fn quantise(v: [f64; 3]) -> [f64; 3] { /// /// Белый полюс на тёмном тоне, чёрный на светлом; граница — кроссовер /// `L ≈ 0.1791` (там оба полюса дают ≈ 4.58:1). Тон квантуется (полюс — свойство -/// ЭМИТИРУЕМОГО тона). Мусор-вход отвергается. -pub fn committed_pole_encoded(tone: [f64; 3]) -> Option { - if !is_encoded_rgb(tone) { - return None; - } - let l = relative_luminance(quantise(tone)); +/// ЭМИТИРУЕМОГО тона). +/// +/// # Errors +/// +/// [`MaterialSolveErrorV1::InvalidTone`] для неконечного канала или канала вне диапазона. +pub fn committed_pole_encoded(tone: [f64; 3]) -> Result { + validate_encoded_rgb(tone).map_err(MaterialSolveErrorV1::InvalidTone)?; + Ok(committed_pole_for_valid_tone(quantise(tone))) +} + +fn committed_pole_for_valid_tone(tone_q: [f64; 3]) -> Pole { + let l = relative_luminance(tone_q); // contrast(белый, L) > contrast(чёрный, L) ⇔ 1.05/(L+0.05) > (L+0.05)/0.05 // ⇔ (L+0.05)² < 0.0525. let s = l + 0.05; - Some(if s * s < 1.05 * 0.05 { + if s * s < 1.05 * 0.05 { Pole::White } else { Pole::Black - }) + } +} + +/// Один канал официального material consumer в зафиксированном byte-scale +/// affine operation order. +fn material_channel_over_byte_scale(tint_byte: f64, alpha: f64, background: f64) -> f64 { + let background_byte_scale = background * 255.0; + (background_byte_scale + alpha * (tint_byte - background_byte_scale)) / 255.0 +} + +/// Conservative encoded-channel range over an ordered background interval. +/// +/// The exact affine reference is monotone in the byte-scale background, but +/// its binary64 evaluation is not. Actual endpoint values are therefore sorted +/// and expanded by [`MATERIAL_COMPOSITE_RANGE_MARGIN`]. At the opaque endpoint, +/// source-over is the tint identity and is represented exactly without a +/// fictitious uncertainty band. +fn material_channel_range( + tint_byte: f64, + alpha: f64, + background_lo: f64, + background_hi: f64, +) -> (f64, f64) { + if alpha == 1.0 { + let tint = tint_byte / 255.0; + return (tint, tint); + } + let at_lo = material_channel_over_byte_scale(tint_byte, alpha, background_lo); + let at_hi = material_channel_over_byte_scale(tint_byte, alpha, background_hi); + let raw_lo = at_lo.min(at_hi); + let raw_hi = at_lo.max(at_hi); + ( + (raw_lo - MATERIAL_COMPOSITE_RANGE_MARGIN).max(0.0), + (raw_hi + MATERIAL_COMPOSITE_RANGE_MARGIN).min(1.0), + ) } -/// Достижимая полоса светлоты композита `[effLow, effHigh]`: тинт над двумя -/// углами короба-коридора. Углы — истинные экстремумы по монотонности (см. -/// шапку); `min`/`max` защищают порядок при перепутанном коробе. +/// Conservative characterized luminance enclosure `[effLow, effHigh]` for the +/// tint over every background in the axis-aligned box. +/// +/// Each actual byte-scale channel is enclosed independently; the legacy WCAG +/// profile then includes both sides of a crossed EOTF seam before combining the +/// positive luminance weights. No RGB-corner or global monotonicity theorem is +/// claimed. fn band_luminance(tint_q: [f64; 3], alpha: f64, backdrop: &BackdropBox) -> (f64, f64) { - debug_assert!( - (0..3).all(|c| backdrop.min[c] <= backdrop.max[c]), - "band_luminance: короб коридора не поканальный (min ≤ max нарушен) — \ - перепутанные углы дали бы неэкстремальную полосу" - ); - let composite = |bg: [f64; 3]| { - [ - alpha * tint_q[0] + (1.0 - alpha) * bg[0], - alpha * tint_q[1] + (1.0 - alpha) * bg[1], - alpha * tint_q[2] + (1.0 - alpha) * bg[2], - ] - }; - let la = relative_luminance(composite(backdrop.min)); - let lb = relative_luminance(composite(backdrop.max)); - (la.min(lb), la.max(lb)) + // Source-over at alpha=1 is exactly the emitted tint for every backdrop in + // this byte-scale profile. Preserve that proven endpoint instead of adding + // an uncertainty band that would falsely degrade #000/#FFF at floor 21. + if alpha == 1.0 { + let luminance = relative_luminance(tint_q); + return (luminance, luminance); + } + let ranges: [(f64, f64); 3] = core::array::from_fn(|channel| { + material_channel_range( + (tint_q[channel] * 255.0).round(), + alpha, + backdrop.min[channel], + backdrop.max[channel], + ) + }); + relative_luminance_range( + core::array::from_fn(|channel| ranges[channel].0), + core::array::from_fn(|channel| ranges[channel].1), + ) } -/// Худший WCAG-контраст полюса по достижимой полосе `[lo, hi]` (честный вердикт, -/// не только на концах): если светлота полюса строго ВНУТРИ полосы — некий фон -/// доводит композит до неё → 1:1; иначе — ближний конец. +/// Худший WCAG-контраст ахроматического полюса по достижимой полосе `[lo, hi]`. +/// Для чёрного полюса ближайший endpoint — `lo`, для белого — `hi`. fn worst_contrast_of_band(pole: Pole, lo: f64, hi: f64) -> f64 { - let p = pole.luminance(); - // Ветка «полюс строго ВНУТРИ полосы → 1:1» недостижима для АХРОМАТИЧЕСКОГО - // полюса (`p ∈ {0, 1}`, полоса ⊆ `[0,1]`, крайние точки не «строго внутри»): - // держится общей на случай будущего не-ахроматического коммит-полюса, а не - // как мёртвый код (тест `committed_pole_maximises_contrast` фиксирует, что - // сегодня полюс всегда ахроматический). - if p > lo && p < hi { - 1.0 - } else { - ratio_from_luminances(p, lo).min(ratio_from_luminances(p, hi)) + match pole { + Pole::Black => ratio_from_luminances(0.0, lo), + Pole::White => ratio_from_luminances(1.0, hi), } } /// Худший WCAG-контраст коммит-полюса тинта `tone` при `alpha` над коридором. /// -/// Тон квантуется (эмитируемый `01`); композит над углами берётся точно. -/// `None` — мусор-вход (не кодированный тон / `alpha` вне `[0,1]`). +/// Тон квантуется (эмитируемый `01`); композитный диапазон использует объявленный +/// порядок binary64-операций без промежуточного квантования, затем conservative +/// channel/EOTF enclosure. +/// # Errors +/// +/// Типизированная [`MaterialSolveErrorV1`] для невалидных tone/alpha либо +/// внутреннего нарушения численного домена. [`BackdropBox`] уже валиден по типу. pub fn worst_contrast_encoded( tone: [f64; 3], alpha: f64, backdrop: &BackdropBox, pole: Pole, -) -> Option { - if !(is_encoded_rgb(tone) - && is_encoded_rgb(backdrop.min) - && is_encoded_rgb(backdrop.max) - && alpha.is_finite() - && (0.0..=1.0).contains(&alpha)) - { - return None; +) -> Result { + validate_encoded_rgb(tone).map_err(MaterialSolveErrorV1::InvalidTone)?; + if !(0.0..=1.0).contains(&alpha) { + return Err(MaterialSolveErrorV1::InvalidAlpha { value: alpha }); } let (lo, hi) = band_luminance(quantise(tone), alpha, backdrop); - Some(worst_contrast_of_band(pole, lo, hi)) + let worst = worst_contrast_of_band(pole, lo, hi); + if !(1.0..=21.0).contains(&worst) { + return Err(MaterialSolveErrorV1::NumericallyIndeterminate); + } + Ok(worst) } -/// Вывести минимальную альфу тинта `01`: наименьшую плотность, при которой -/// композит тона над ХУДШИМ фоном коридора держит коммит-полюс на поле -/// `floor_ratio` (WCAG-отношение, напр. 4.5). +fn validate_rechecked_bracket( + lower_contrast: f64, + upper_contrast: f64, + floor_ratio: f64, +) -> Result<(), MaterialSolveErrorV1> { + if !matches!( + lower_contrast.partial_cmp(&floor_ratio), + Some(core::cmp::Ordering::Less) + ) { + return Err(MaterialSolveErrorV1::NumericallyIndeterminate); + } + if !matches!( + upper_contrast.partial_cmp(&floor_ratio), + Some(core::cmp::Ordering::Equal | core::cmp::Ordering::Greater) + ) { + return Err(MaterialSolveErrorV1::NumericallyIndeterminate); + } + Ok(()) +} + +/// Выбрать проходящую альфу тинта `01` бинарной partition с фиксированным числом +/// шагов и повторно проверить найденные fail/pass endpoints. /// -/// Бисекция по `α ∈ (0, 1]` (худший контраст монотонно растёт по `α`), замер на -/// квантованном тоне-тинте. Верхняя сторона брекета гарантирует -/// `worst_contrast ≥ floor_ratio` на возвращённой `α`. При недостижимости пола -/// даже на `α = 1` — честный `degraded` (не ошибка, не молчание). +/// Проверка endpoint, затем детерминированная бинарная partition по +/// `α ∈ [0, 1]` и замер на квантованном тоне-тинте. Каждый шаг сохраняет +/// rechecked fail/pass endpoints; глобальная монотонность или первый passing +/// state не заявляются. Возвращённое состояние +/// несёт [`MaterialAlphaGuaranteeV1`]: зависящая от платформы и toolchain +/// функция `powf` не выдаётся за sound-доказательство точной межсредовой +/// минимальной границы. При недостижимости пола даже на `α = 1` возвращается +/// типизированный degraded-исход, а не ошибка или fallback. /// -/// `None` — вход вне домена: не кодированный `tone` / `floor_ratio` не в -/// `[1, 21]` (WCAG-отношение); ЛИБО тон лежит ВНЕ короба коридора со стороны -/// полюса, где худший контраст НЕ монотонен по `α` и бисекция неприменима (см. -/// ниже). Материальный путь (полный коридор `[чёрный, белый]`) в этот `None` -/// никогда не попадает — тон всегда в кубе. +/// # Errors +/// +/// Типизированный невалидный tone/floor или +/// [`MaterialSolveErrorV1::UnsupportedDirectedSearchRelation`]. +/// Материальный путь с [`BackdropBox::FULL`] всегда лежит в направленном домене. pub fn solve_material_alpha_encoded( tone: [f64; 3], backdrop: &BackdropBox, floor_ratio: f64, -) -> Option { - if !(is_encoded_rgb(tone) - && is_encoded_rgb(backdrop.min) - && is_encoded_rgb(backdrop.max) - && floor_ratio.is_finite() - && (1.0..=21.0).contains(&floor_ratio)) - { - return None; +) -> Result { + validate_encoded_rgb(tone).map_err(MaterialSolveErrorV1::InvalidTone)?; + if !(1.0..=21.0).contains(&floor_ratio) { + return Err(MaterialSolveErrorV1::InvalidFloorRatio { value: floor_ratio }); } let tone_q = quantise(tone); - let pole = committed_pole_encoded(tone_q)?; - // Бисекция корректна ЛИШЬ когда худший контраст монотонно растёт по `α`, а это - // держится только если тон лежит в коробе коридора со стороны полюса: у чёрного - // полюса композит над темнейшим углом обязан СВЕТЛЕТЬ с ростом `α` (нужно - // `tone ≥ min` поканально), у белого — композит над светлейшим углом обязан - // ТЕМНЕТЬ (`tone ≤ max`). Полный коридор `[чёрный, белый]` гарантирует это - // всегда (материальный путь); узкий коридор с тоном ВНЕ короба со стороны - // полюса выводит из монотонного домена — тогда бисекция дала бы ложную `α`/ - // деградацию, поэтому честный `None` (дисциплина «мусор-вход → None»), а не - // молчаливо неверный ответ. Обобщённый узкий коридор (ADR-0004) с тоном вне - // короба потребует иного солвера (скан/тернарный поиск), не бисекции. - let monotone = match pole { + let pole = committed_pole_for_valid_tone(tone_q); + let worst_at = |alpha: f64| -> Result { + let (lo, hi) = band_luminance(tone_q, alpha, backdrop); + let worst = worst_contrast_of_band(pole, lo, hi); + if !(1.0..=21.0).contains(&worst) { + return Err(MaterialSolveErrorV1::NumericallyIndeterminate); + } + Ok(worst) + }; + + // Общий закон endpoint: если полностью прозрачный tint уже держит floor, + // порогового поиска не существует и выдумывать неуспешную нижнюю границу нельзя. + let transparent = worst_at(0.0)?; + if transparent >= floor_ratio { + return Ok(MaterialAlpha { + alpha: 0.0, + worst_contrast: transparent, + pole, + status: MaterialAlphaStatusV1::Satisfied, + guarantee: MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + }, + }); + } + + // Density search is supported only in the direction that contracts the + // backdrop interval toward the committed tone: black requires tone >= min, + // white requires tone <= max. This scopes the selection policy; it is not a + // claim that the branch-sensitive binary64 predicate is globally monotone. + // The transparent endpoint above does not require this precondition. + let directed = match pole { Pole::Black => (0..3).all(|c| tone_q[c] >= backdrop.min[c]), Pole::White => (0..3).all(|c| tone_q[c] <= backdrop.max[c]), }; - if !monotone { - return None; + if !directed { + return Err(MaterialSolveErrorV1::UnsupportedDirectedSearchRelation { pole }); } - let worst_at = |alpha: f64| { - let (lo, hi) = band_luminance(tone_q, alpha, backdrop); - worst_contrast_of_band(pole, lo, hi) - }; // α = 1: полоса вырождается в L(tone) → худший контраст = контраст полюса на // тоне (солид-канон). Если и он ниже пола — честная деградация (ближайшее // достижимое), гарантия не выполнена. - let opaque = worst_at(1.0); + let opaque = worst_at(1.0)?; if opaque < floor_ratio { - return Some(MaterialAlpha { + return Ok(MaterialAlpha { alpha: 1.0, worst_contrast: opaque, pole, - degraded: true, + status: MaterialAlphaStatusV1::Degraded, + guarantee: MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + }, }); } - // Бисекция минимальной годной α. lo держит НЕгодную сторону, hi — годную. + // Binary partition with a fixed number of steps: lo is rechecked failing and + // hi rechecked passing after every update. No global-first/minimum claim is + // made for the discontinuous legacy predicate. let (mut lo, mut hi) = (0.0_f64, 1.0_f64); - for _ in 0..60 { + for _ in 0..MATERIAL_ALPHA_BISECTION_ITERATIONS { let mid = 0.5 * (lo + hi); - if worst_at(mid) >= floor_ratio { + if worst_at(mid)? >= floor_ratio { hi = mid; } else { lo = mid; } } - Some(MaterialAlpha { + let worst_contrast = worst_at(hi)?; + let lower_contrast = worst_at(lo)?; + validate_rechecked_bracket(lower_contrast, worst_contrast, floor_ratio)?; + Ok(MaterialAlpha { alpha: hi, - worst_contrast: worst_at(hi), + worst_contrast, pole, - degraded: false, + status: MaterialAlphaStatusV1::Satisfied, + guarantee: MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations: MATERIAL_ALPHA_BISECTION_ITERATIONS, + lower_alpha: lo, + upper_alpha: hi, + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + }, }) } @@ -286,11 +830,14 @@ pub fn solve_material_alpha_encoded( /// /// # Errors /// -/// `Err` при невалидном hex или `floor_ratio` вне `[1, 21]`. -pub fn solve_material_alpha_hex(tone_hex: &str, floor_ratio: f64) -> Result { - let tone = srgb_encoded_from_hex(tone_hex)?; +/// Типизированная [`MaterialSolveErrorV1`] при невалидном hex/floor. +pub fn solve_material_alpha_hex( + tone_hex: &str, + floor_ratio: f64, +) -> Result { + let tone = srgb_encoded_from_hex(tone_hex) + .map_err(|reason| MaterialSolveErrorV1::InvalidToneHex { reason })?; solve_material_alpha_encoded(tone, &BackdropBox::FULL, floor_ratio) - .ok_or_else(|| format!("floor_ratio вне домена [1,21]: {floor_ratio}")) } #[cfg(test)] @@ -298,6 +845,7 @@ mod tests { use super::*; use crate::alpha::composite_over_encoded; use crate::wcag::contrast_ratio; + use proptest::prelude::*; const AA_TEXT: f64 = 4.5; @@ -313,7 +861,8 @@ mod tests { let t = enc(hex); let tq = quantise(t); for alpha in [0.01, 0.1, 0.5, 0.837, 1.0] { - let solid = composite_over_encoded(tq, alpha, tq); + let solid = composite_over_encoded(tq, alpha, tq) + .expect("тестовые sRGB-каналы и alpha лежат в домене"); assert_eq!( hex_from_srgb_encoded(solid), hex_from_srgb_encoded(tq), @@ -323,76 +872,89 @@ mod tests { } } - /// На выведенной α худший фон держит пол, а ЧУТЬ НИЖЕ α — рвёт его: α есть - /// ТОЧНАЯ граница годности (реальный солвер-пол, не подгонка). + /// Выбранный верхний кандидат повторно держит floor в объявленном runtime. + /// Отдельная проба на 2% ниже делает fixture невакуумным, но не выдаётся за + /// доказательство predecessor или минимальности без sound bound для `powf`. #[test] - fn solved_alpha_is_the_exact_floor_boundary() { + fn selected_alpha_rechecks_and_fixture_has_a_non_vacuous_threshold() { for hex in ["#E4E4E6", "#B0B0B8", "#35353A", "#2A2A30", "#5C5C5C"] { let m = solve_material_alpha_hex(hex, AA_TEXT).unwrap(); - assert!(!m.degraded, "{hex}: неожиданная деградация на AA"); - assert!(m.alpha > 0.0 && m.alpha <= 1.0, "{hex}: α вне (0,1]"); - let pole = m.pole; + assert!(!m.degraded(), "{hex}: неожиданная деградация на AA"); + assert!(m.alpha() > 0.0 && m.alpha() <= 1.0, "{hex}: α вне (0,1]"); + let pole = m.pole(); let at = |a: f64| worst_contrast_encoded(enc(hex), a, &BackdropBox::FULL, pole).unwrap(); - // На α держит пол (с крошечным допуском на брекет бисекции). + // Финальная повторная runtime-проверка обязана держать floor. assert!( - at(m.alpha) >= AA_TEXT - 1e-9, + at(m.alpha()) >= AA_TEXT - 1e-9, "{hex}: на α={} худший контраст {} < пола", - m.alpha, - at(m.alpha) + m.alpha(), + at(m.alpha()) ); - // Чуть ниже α — рвёт (если α ещё не у самого пола нуля). - if m.alpha > 1e-3 { + // Грубая anti-vacuum характеризация, а не сертификат минимальности. + if m.alpha() > 1e-3 { assert!( - at(m.alpha * 0.98) < AA_TEXT, - "{hex}: годно НИЖЕ α={} — граница не минимальна", - m.alpha + at(m.alpha() * 0.98) < AA_TEXT, + "{hex}: 2%-lower anti-vacuum probe unexpectedly passes for α={}", + m.alpha() ); } } } - /// Худший контраст монотонно НЕ убывает по α (полоса стягивается к тону) — - /// инвариант, на котором стоит бисекция. Свип на тонах обеих полярностей. + /// Frozen legacy EOTF имеет downward seam, поэтому даже направленный + /// material path нельзя объявлять глобально монотонным по alpha. #[test] - fn worst_contrast_is_monotone_in_alpha() { - for hex in ["#EDEDEF", "#C8C8CE", "#9A9AA2", "#40404A", "#1C1C1F"] { - let tone = enc(hex); - let pole = committed_pole_encoded(tone).unwrap(); - let mut prev = 0.0; - for i in 1..=100 { - let a = f64::from(i) / 100.0; - let w = worst_contrast_encoded(tone, a, &BackdropBox::FULL, pole).unwrap(); - assert!( - w >= prev - 1e-9, - "{hex}: худший контраст упал на α={a}: {w} < {prev}" - ); - prev = w; - } - } + fn legacy_eotf_seam_rejects_global_alpha_monotonicity() { + let alpha = 0.039_28_f64; + let next_alpha = f64::from_bits(alpha.to_bits() + 1); + let at = material_channel_over_byte_scale(255.0, alpha, 0.0); + let after = material_channel_over_byte_scale(255.0, next_alpha, 0.0); + assert_eq!(at.to_bits(), alpha.to_bits()); + assert_eq!(after.to_bits(), next_alpha.to_bits()); + + let before_luminance = relative_luminance([at; 3]); + let after_luminance = relative_luminance([after; 3]); + assert!( + after_luminance < before_luminance, + "legacy seam witness stopped rejecting global monotonicity" + ); } - /// Гарантия воспроизводима из эмитированных значений (гарантия (c) контракта): - /// композит квантованного тинта над чёрным/белым точно даёт худший контраст, - /// совпадающий с вердиктом солвера — потребитель пересчитает то же число. + /// Гарантия проверяема из эмитированных значений: dense independent backdrop + /// probes используют официальный scalar compositor, а conservative verdict + /// ядра не превышает ни один измеренный probe. #[test] fn guarantee_recomputable_from_emitted_tint() { for hex in ["#E9E9EB", "#A0A0A8", "#313135"] { let m = solve_material_alpha_hex(hex, AA_TEXT).unwrap(); let tint_q = quantise(enc(hex)); - // Пересчёт «в лоб» как у потребителя: композит над двумя углами. - let over_black = composite_over_encoded(tint_q, m.alpha, [0.0; 3]); - let over_white = composite_over_encoded(tint_q, m.alpha, [1.0; 3]); - let pole_lum = m.pole.luminance(); - let recomputed = ratio_from_luminances(pole_lum, relative_luminance(over_black)).min( - ratio_from_luminances(pole_lum, relative_luminance(over_white)), - ); + let pole_lum = m.pole().luminance(); + let probes = [0.0, 0.039_28, 0.039_280_000_000_000_01, 0.5, 1.0]; + let mut measured_min = f64::INFINITY; + for red in probes { + for green in probes { + for blue in probes { + let background = [red, green, blue]; + let composite = core::array::from_fn(|channel| { + material_channel_over_byte_scale( + (tint_q[channel] * 255.0).round(), + m.alpha(), + background[channel], + ) + }); + let measured = + ratio_from_luminances(pole_lum, relative_luminance(composite)); + measured_min = measured_min.min(measured); + } + } + } assert!( - (recomputed - m.worst_contrast).abs() < 1e-9, - "{hex}: пересчёт {recomputed} != вердикту {}", - m.worst_contrast + m.worst_contrast() <= measured_min, + "{hex}: conservative verdict {} exceeded measured probe {measured_min}", + m.worst_contrast() ); - assert!(recomputed >= AA_TEXT - 1e-9, "{hex}: пересчёт ниже пола"); + assert!(m.worst_contrast() >= AA_TEXT, "{hex}: verdict ниже пола"); } } @@ -429,49 +991,54 @@ mod tests { let g = f64::from(i) / 255.0; let m = solve_material_alpha_encoded([g, g, g], &BackdropBox::FULL, AA_TEXT).unwrap(); assert!( - !m.degraded, + !m.degraded(), "серый {i}: AA обязан быть разрешим без деградации" ); - assert!(m.alpha > 0.0 && m.alpha <= 1.0); + assert!(m.alpha() > 0.0 && m.alpha() <= 1.0); } // Насыщенные тоны разных светлот/оттенков — не только серые. for hex in [ "#3E87FF", "#FF3B30", "#34C759", "#FFCC00", "#AF52DE", "#007AFF", "#B03030", "#0A3A6B", ] { let m = solve_material_alpha_hex(hex, AA_TEXT).unwrap(); - assert!(!m.degraded, "{hex}: AA обязан быть разрешим без деградации"); - assert!(m.alpha > 0.0 && m.alpha <= 1.0); + assert!( + !m.degraded(), + "{hex}: AA обязан быть разрешим без деградации" + ); + assert!(m.alpha() > 0.0 && m.alpha() <= 1.0); } } - /// Guard монотонности: узкий коридор с тоном ВНЕ короба со стороны полюса - /// (где худший контраст НЕ монотонен по α) честно отвергается солвером — а не - /// возвращает ложную α/деградацию. Контрпример из независимой верификации: + /// Directed-search guard: узкий коридор с тоном ВНЕ короба со стороны полюса + /// честно отвергается, а не получает произвольную alpha/degradation. + /// Контрпример из независимой верификации: /// тон `#8A8A8A` (Lum≈0.25, чёрный полюс) над коридором `[#B3B3B3, белый]` /// (все фоны СВЕТЛЕЕ тона) — `tone < min`, немонотонно. #[test] fn guard_rejects_non_monotone_narrow_corridor() { let tone = enc("#8A8A8A"); - assert_eq!(committed_pole_encoded(tone), Some(Pole::Black)); - let bad = BackdropBox { - min: enc("#B3B3B3"), - max: [1.0; 3], - }; - // Немонотонный домен → честный None, не ложная деградация. - assert!( - solve_material_alpha_encoded(tone, &bad, 7.0).is_none(), - "тон вне короба со стороны полюса обязан быть отвергнут (немонотонно)" - ); - // Но worst_contrast_encoded НЕ предполагает монотонность — считает верно. - assert!(worst_contrast_encoded(tone, 0.5, &bad, Pole::Black).is_some()); - // Монотонный узкий коридор (тон ВНУТРИ короба, tone ≥ min) — решается. - let good = BackdropBox { - min: enc("#202020"), - max: [1.0; 3], - }; + assert_eq!(committed_pole_encoded(tone), Ok(Pole::Black)); + let bad = BackdropBox::try_new(enc("#B3B3B3"), [1.0; 3]).unwrap(); + // Неподдерживаемое направление → typed error, не ложная деградация. + assert!(matches!( + solve_material_alpha_encoded(tone, &bad, 15.0), + Err(MaterialSolveErrorV1::UnsupportedDirectedSearchRelation { pole: Pole::Black }) + )); + let transparent = solve_material_alpha_encoded(tone, &bad, 1.0) + .expect("transparent endpoint does not require monotone threshold search"); + assert_eq!(transparent.alpha().to_bits(), 0.0_f64.to_bits()); + assert_eq!(transparent.status(), MaterialAlphaStatusV1::Satisfied); + assert!(matches!( + transparent.guarantee(), + MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { .. } + )); + // worst_contrast_encoded сам не применяет selection guard. + assert!(worst_contrast_encoded(tone, 0.5, &bad, Pole::Black).is_ok()); + // Поддерживаемое направление (тон ВНУТРИ короба, tone ≥ min) — решается. + let good = BackdropBox::try_new(enc("#202020"), [1.0; 3]).unwrap(); assert!( - solve_material_alpha_encoded(tone, &good, AA_TEXT).is_some(), - "тон в коробе — монотонный домен, обязан решиться" + solve_material_alpha_encoded(tone, &good, AA_TEXT).is_ok(), + "тон в коробе — поддерживаемое направление, обязан решиться" ); } @@ -500,14 +1067,23 @@ mod tests { // Средний тон: полюс максимального контраста ≈ 4.6, ниже 7:1. let m = solve_material_alpha_encoded([0.42, 0.42, 0.42], &BackdropBox::FULL, 7.0).unwrap(); assert!( - m.degraded, + m.degraded(), "средний тон обязан деградировать на AAA-поле 7:1" ); assert_eq!( - m.alpha, 1.0, + m.alpha(), + 1.0, "деградация возвращает ближайшую достижимую α=1" ); - assert!(m.worst_contrast < 7.0); + assert!(m.worst_contrast() < 7.0); + assert_eq!(m.status(), MaterialAlphaStatusV1::Degraded); + assert!(matches!( + m.guarantee(), + MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + } + )); } /// Узкий коридор требует МЕНЬШЕ плотности, чем полный [чёрный, белый]: @@ -517,16 +1093,13 @@ mod tests { let tone = enc("#C8C8CE"); let full = solve_material_alpha_encoded(tone, &BackdropBox::FULL, AA_TEXT).unwrap(); // Узкий светлый коридор: фон только в [#C0.., #FF..]. - let narrow = BackdropBox { - min: enc("#C0C0C0"), - max: [1.0; 3], - }; + let narrow = BackdropBox::try_new(enc("#C0C0C0"), [1.0; 3]).unwrap(); let m = solve_material_alpha_encoded(tone, &narrow, AA_TEXT).unwrap(); assert!( - m.alpha <= full.alpha + 1e-12, + m.alpha() <= full.alpha() + 1e-12, "узкий коридор потребовал не меньше полного: {} vs {}", - m.alpha, - full.alpha + m.alpha(), + full.alpha() ); } @@ -534,18 +1107,415 @@ mod tests { /// обещанием разрешимости). #[test] fn out_of_domain_is_rejected() { + let point = BackdropBox::try_new([0.25, 0.5, 0.75], [0.25, 0.5, 0.75]) + .expect("degenerate box remains valid"); + assert!(worst_contrast_encoded([1.0; 3], 0.5, &point, Pole::Black).is_ok()); + + assert!(matches!( + solve_material_alpha_encoded([1.5, 0.0, 0.0], &BackdropBox::FULL, AA_TEXT), + Err(MaterialSolveErrorV1::InvalidTone( + EncodedRgbErrorV1::OutOfRangeChannel { + channel: RgbChannelV1::Red, + value: 1.5, + } + )) + )); + assert!(matches!( + solve_material_alpha_encoded([f64::NAN, 0.5, 0.5], &BackdropBox::FULL, AA_TEXT), + Err(MaterialSolveErrorV1::InvalidTone( + EncodedRgbErrorV1::NonFiniteChannel { + channel: RgbChannelV1::Red, + } + )) + )); + for floor in [0.5, 25.0, f64::NAN] { + assert!(matches!( + solve_material_alpha_encoded([0.5; 3], &BackdropBox::FULL, floor), + Err(MaterialSolveErrorV1::InvalidFloorRatio { value }) + if value.to_bits() == floor.to_bits() + )); + } + assert!(matches!( + committed_pole_encoded([2.0, 0.0, 0.0]), + Err(MaterialSolveErrorV1::InvalidTone(_)) + )); + assert!(matches!( + worst_contrast_encoded([0.5; 3], 1.5, &BackdropBox::FULL, Pole::Black), + Err(MaterialSolveErrorV1::InvalidAlpha { value: 1.5 }) + )); + assert!(matches!( + solve_material_alpha_hex("нет", AA_TEXT), + Err(MaterialSolveErrorV1::InvalidToneHex { .. }) + )); + } + + #[test] + fn backdrop_box_constructor_reports_distinct_invariant_failures() { + assert!(matches!( + BackdropBox::try_new([1.0, 0.0, 0.0], [0.0, 1.0, 1.0]), + Err(BackdropBoxErrorV1::ReversedChannel { + channel: RgbChannelV1::Red, + min: 1.0, + max: 0.0, + }) + )); + assert!(matches!( + BackdropBox::try_new([0.0; 3], [f64::NAN, 1.0, 1.0]), + Err(BackdropBoxErrorV1::NonFiniteChannel { + bound: BackdropBoundV1::Max, + channel: RgbChannelV1::Red, + }) + )); + assert!(matches!( + BackdropBox::try_new([-0.01, 0.0, 0.0], [1.0; 3]), + Err(BackdropBoxErrorV1::OutOfRangeChannel { + bound: BackdropBoundV1::Min, + channel: RgbChannelV1::Red, + value: -0.01, + }) + )); + + let point = BackdropBox::try_new([0.25, 0.5, 0.75], [0.25, 0.5, 0.75]) + .expect("degenerate ordered box is a valid reachable set"); + assert_eq!(point.min(), [0.25, 0.5, 0.75]); + assert_eq!(point.max(), [0.25, 0.5, 0.75]); + } + + #[test] + fn rechecked_bracket_postcondition_rejects_each_invalid_side_independently() { + assert!(validate_rechecked_bracket(2.99, 3.0, 3.0).is_ok()); + for (lower, upper) in [ + (3.0, 3.0), + (3.01, 3.0), + (f64::NAN, 3.0), + (2.99, 2.99), + (2.99, f64::NAN), + ] { + assert_eq!( + validate_rechecked_bracket(lower, upper, 3.0), + Err(MaterialSolveErrorV1::NumericallyIndeterminate), + "lower={lower}, upper={upper}", + ); + } + } + + #[test] + fn material_public_api_has_typed_failures_and_explicit_numerical_guarantee() { + assert!(matches!( + worst_contrast_encoded([0.5; 3], f64::NAN, &BackdropBox::FULL, Pole::Black), + Err(MaterialSolveErrorV1::InvalidAlpha { .. }) + )); + assert!(matches!( + solve_material_alpha_encoded([f64::NAN, 0.5, 0.5], &BackdropBox::FULL, AA_TEXT), + Err(MaterialSolveErrorV1::InvalidTone( + EncodedRgbErrorV1::NonFiniteChannel { + channel: RgbChannelV1::Red, + } + )) + )); + assert!(matches!( + solve_material_alpha_encoded([0.5; 3], &BackdropBox::FULL, 25.0), + Err(MaterialSolveErrorV1::InvalidFloorRatio { value: 25.0 }) + )); + + let non_monotone = BackdropBox::try_new(enc("#B3B3B3"), [1.0; 3]).unwrap(); + assert!(matches!( + solve_material_alpha_encoded(enc("#8A8A8A"), &non_monotone, 15.0), + Err(MaterialSolveErrorV1::UnsupportedDirectedSearchRelation { pole: Pole::Black }) + )); + + let solved = solve_material_alpha_hex("#E4E4E6", AA_TEXT).unwrap(); + assert_eq!(solved.status(), MaterialAlphaStatusV1::Satisfied); + let (lower_alpha, upper_alpha) = match solved.guarantee() { + MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations: 60, + lower_alpha, + upper_alpha, + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + } => (lower_alpha, upper_alpha), + other => panic!("expected characterized threshold bracket, got {other:?}"), + }; + assert_eq!(solved.alpha().to_bits(), upper_alpha.to_bits()); + assert!( + worst_contrast_encoded( + enc("#E4E4E6"), + lower_alpha, + &BackdropBox::FULL, + solved.pole(), + ) + .unwrap() + < AA_TEXT + ); + let recomputed = worst_contrast_encoded( + enc("#E4E4E6"), + solved.alpha(), + &BackdropBox::FULL, + solved.pole(), + ) + .unwrap(); + assert_eq!(recomputed.to_bits(), solved.worst_contrast().to_bits()); + assert!(recomputed >= AA_TEXT); + + let transparent = solve_material_alpha_hex("#E4E4E6", 1.0).unwrap(); + assert_eq!(transparent.alpha().to_bits(), 0.0_f64.to_bits()); + assert_eq!(transparent.status(), MaterialAlphaStatusV1::Satisfied); + assert!(matches!( + transparent.guarantee(), + MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { + numerical_profile: + MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + } + )); + assert!(transparent.worst_contrast() >= 1.0); + } + + #[test] + fn selected_alpha_rechecks_in_the_official_byte_scale_affine_order() { + for (tone_hex, floor, alpha_bits) in [ + ("#020202", 3.0, 0x3fda_d867_f596_0b7c), + ("#000000", 7.0, 0x3fe4_d36f_0c15_b3eb), + ("#000000", 19.7963, 0x3fee_be38_c41e_493b), + ] { + let solved = solve_material_alpha_hex(tone_hex, floor).unwrap(); + assert_eq!(solved.alpha().to_bits(), alpha_bits); + let tint = quantise(enc(tone_hex)); + let composite = |background: [f64; 3]| { + core::array::from_fn(|channel| { + let tint_byte = (tint[channel] * 255.0).round(); + let background_byte_scale = background[channel] * 255.0; + (background_byte_scale + solved.alpha() * (tint_byte - background_byte_scale)) + / 255.0 + }) + }; + let lo = relative_luminance(composite([0.0; 3])); + let hi = relative_luminance(composite([1.0; 3])); + let consumer_worst = worst_contrast_of_band(solved.pole(), lo, hi); + assert!( + consumer_worst >= solved.worst_contrast(), + "{tone_hex}: conservative core bound {} exceeded endpoint consumer {consumer_worst}", + solved.worst_contrast() + ); + assert!( + consumer_worst >= floor, + "{tone_hex}: reported Satisfied state misses requested floor in the official consumer" + ); + } + } + + #[test] + fn valid_material_fixture_bits_are_characterized_across_the_typed_migration() { + let fixtures = [ + ( + "#E4E4E6", + 0x3fe0_4922_26de_cb0a, + 0x4012_0000_0000_0000, + Pole::Black, + ), + ( + "#B0B0B8", + 0x3fe5_0a37_d1ac_0292, + 0x4012_0000_0000_0000, + Pole::Black, + ), + ( + "#35353A", + 0x3fe5_a367_f8ad_9e98, + 0x4012_0000_0000_0001, + Pole::White, + ), + ( + "#2A2A30", + 0x3fe4_86ad_a430_0884, + 0x4012_0000_0000_0000, + Pole::White, + ), + ( + "#5C5C5C", + 0x3fea_c450_4ad4_a737, + 0x4012_0000_0000_0000, + Pole::White, + ), + ]; + for (hex, alpha_bits, worst_bits, pole) in fixtures { + let solved = solve_material_alpha_hex(hex, AA_TEXT).unwrap(); + assert_eq!(solved.alpha().to_bits(), alpha_bits, "{hex}: alpha drift"); + assert_eq!( + solved.worst_contrast().to_bits(), + worst_bits, + "{hex}: worst contrast drift" + ); + assert_eq!(solved.pole(), pole, "{hex}: pole drift"); + assert_eq!(solved.status(), MaterialAlphaStatusV1::Satisfied); + } + } + + #[test] + fn interior_wcag_eotf_seam_cannot_undercut_a_satisfied_full_box_result() { + let floor = 19.7963; + let solved = solve_material_alpha_hex("#000000", floor).unwrap(); + assert_eq!(solved.status(), MaterialAlphaStatusV1::Satisfied); + assert!(solved.worst_contrast() >= floor); + + // Допустимый interior backdrop переводит byte-scale composite ровно к + // discontinuity frozen legacy WCAG 2.1 (2018) split 0.03928. Старый + // corner-only band пропускал эту точку и завышал all-backdrops verdict. + let gray = 0.999_762_480_394_283_1; + let point = BackdropBox::try_new([gray; 3], [gray; 3]).unwrap(); + let actual = + worst_contrast_encoded(enc("#000000"), solved.alpha(), &point, solved.pole()).unwrap(); + assert!( + actual >= floor, + "full-box Satisfied undercut at interior EOTF seam: {actual} < {floor}" + ); + } + + #[test] + fn channel_range_encloses_known_binary64_interior_jitter() { + let alpha = f64::from_bits(1.0_f64.to_bits() - 1); + let tint_byte = 2.0; + let interior_byte_scale = 0.137_643_568_434_543_95; + let interior_background = interior_byte_scale / 255.0; + let actual = material_channel_over_byte_scale(tint_byte, alpha, interior_background); + let endpoint_min = material_channel_over_byte_scale(tint_byte, alpha, 0.0) + .min(material_channel_over_byte_scale(tint_byte, alpha, 1.0)); + assert!( + actual < endpoint_min, + "fixture must reject endpoint-only affine monotonicity" + ); + + let (lo, hi) = material_channel_range(tint_byte, alpha, 0.0, 1.0); assert!( - solve_material_alpha_encoded([1.5, 0.0, 0.0], &BackdropBox::FULL, AA_TEXT).is_none() + lo <= actual && actual <= hi, + "{actual} outside [{lo}, {hi}]" ); + } + + #[test] + fn composite_range_margin_dominates_pairwise_error_and_outward_rounding() { + let single = std::hint::black_box(MATERIAL_COMPOSITE_SINGLE_EVALUATION_ERROR_BOUND); + let pairwise = std::hint::black_box(MATERIAL_COMPOSITE_PAIRWISE_ERROR_BOUND); + let outward = std::hint::black_box(MATERIAL_COMPOSITE_OUTWARD_ROUNDING_BOUND); + let margin = std::hint::black_box(MATERIAL_COMPOSITE_RANGE_MARGIN); + assert!(single < 3.90e-16); + assert_eq!(pairwise, 2.0 * single); + let required = pairwise + outward; + assert!(margin > required); + assert!(margin > 1.9 * required); + } + + #[test] + fn scalar_compositor_can_decrease_across_adjacent_backgrounds_but_range_encloses_both() { + let tint_byte = 228.0; + let alpha = 0.618_654_751_765_414_3; + let byte_scale = 73.103_401_561_508_89_f64; + let next_byte_scale = f64::from_bits(byte_scale.to_bits() + 1); + let background = byte_scale / 255.0; + let next_background = next_byte_scale / 255.0; + let at = material_channel_over_byte_scale(tint_byte, alpha, background); + let next = material_channel_over_byte_scale(tint_byte, alpha, next_background); assert!( - solve_material_alpha_encoded([f64::NAN, 0.5, 0.5], &BackdropBox::FULL, AA_TEXT) - .is_none() + next < at, + "fixture must reject binary64 background monotonicity" ); - assert!(solve_material_alpha_encoded([0.5; 3], &BackdropBox::FULL, 0.5).is_none()); - assert!(solve_material_alpha_encoded([0.5; 3], &BackdropBox::FULL, 25.0).is_none()); - assert!(solve_material_alpha_encoded([0.5; 3], &BackdropBox::FULL, f64::NAN).is_none()); - assert!(committed_pole_encoded([2.0, 0.0, 0.0]).is_none()); - assert!(worst_contrast_encoded([0.5; 3], 1.5, &BackdropBox::FULL, Pole::Black).is_none()); - assert!(solve_material_alpha_hex("нет", AA_TEXT).is_err()); + + let (lo, hi) = material_channel_range(tint_byte, alpha, background, next_background); + assert!(lo <= at && at <= hi); + assert!(lo <= next && next <= hi); + } + + #[test] + fn opaque_endpoint_is_exact_tint_identity() { + for tint_byte in [0.0, 1.0, 2.0, 127.0, 228.0, 254.0, 255.0] { + let expected = tint_byte / 255.0; + let (lo, hi) = material_channel_range(tint_byte, 1.0, 0.0, 1.0); + assert_eq!(lo.to_bits(), expected.to_bits()); + assert_eq!(hi.to_bits(), expected.to_bits()); + for background in [0.0, f64::EPSILON, 0.137_643_568_434_543_95, 0.5, 1.0] { + assert_eq!( + material_channel_over_byte_scale(tint_byte, 1.0, background).to_bits(), + expected.to_bits(), + "tint={tint_byte}, background={background}" + ); + } + } + } + + #[test] + fn extreme_opaque_endpoints_hold_exact_twentyone_to_one() { + for hex in ["#000000", "#FFFFFF"] { + let solved = solve_material_alpha_hex(hex, 21.0).unwrap(); + assert_eq!(solved.status(), MaterialAlphaStatusV1::Satisfied, "{hex}"); + assert_eq!(solved.alpha().to_bits(), 1.0_f64.to_bits(), "{hex}"); + assert_eq!( + solved.worst_contrast().to_bits(), + 21.0_f64.to_bits(), + "{hex}" + ); + } + } + + #[test] + fn point_and_ordered_boxes_preserve_characterized_material_result_bits() { + let tone = enc("#C8C8CE"); + let fixtures = [ + ( + BackdropBox::try_new(enc("#202020"), enc("#202020")).unwrap(), + 0x3fdf_f53f_9eff_0967, + 0x4012_0000_0000_0000, + ), + ( + BackdropBox::try_new(enc("#303030"), enc("#A0A0A0")).unwrap(), + 0x3fdc_9853_0c20_50b1, + 0x4012_0000_0000_0000, + ), + ]; + + for (backdrop, alpha_bits, worst_bits) in fixtures { + let solved = solve_material_alpha_encoded(tone, &backdrop, AA_TEXT).unwrap(); + assert_eq!(solved.alpha().to_bits(), alpha_bits); + assert_eq!(solved.worst_contrast().to_bits(), worst_bits); + assert_eq!(solved.pole(), Pole::Black); + assert_eq!(solved.status(), MaterialAlphaStatusV1::Satisfied); + } + } + + proptest! { + #[test] + fn backdrop_constructor_accepts_exactly_finite_ordered_encoded_boxes( + min in proptest::array::uniform3(any::()), + max in proptest::array::uniform3(any::()), + ) { + let expected = min.into_iter().chain(max).all(|value| { + value.is_finite() && (0.0..=1.0).contains(&value) + }) && (0..3).all(|channel| min[channel] <= max[channel]); + prop_assert_eq!(BackdropBox::try_new(min, max).is_ok(), expected); + } + + #[test] + fn material_channel_range_encloses_official_scalar_compositor( + tint_byte in any::(), + alpha in 0.0_f64..1.0_f64, + background_a in 0.0_f64..1.0_f64, + background_b in 0.0_f64..1.0_f64, + position in 0.0_f64..1.0_f64, + ) { + let background_lo = background_a.min(background_b); + let background_hi = background_a.max(background_b); + let background = background_lo + position * (background_hi - background_lo); + let tint_byte = f64::from(tint_byte); + let actual = material_channel_over_byte_scale(tint_byte, alpha, background); + let (lo, hi) = material_channel_range( + tint_byte, + alpha, + background_lo, + background_hi, + ); + prop_assert!(lo.is_finite() && hi.is_finite()); + prop_assert!((0.0..=1.0).contains(&lo)); + prop_assert!((0.0..=1.0).contains(&hi)); + prop_assert!(lo <= actual, "{actual} below {lo}"); + prop_assert!(actual <= hi, "{actual} above {hi}"); + } } } diff --git a/crates/labcolors-core/src/neutral.rs b/crates/labcolors-core/src/neutral.rs index c1c51815..b929b839 100644 --- a/crates/labcolors-core/src/neutral.rs +++ b/crates/labcolors-core/src/neutral.rs @@ -61,14 +61,16 @@ const HUE_PURITY_MP_REF_RATIO: f64 = 1.5; // SSOT-TRACKED — показатель кривой чистоты, терминал (e) design-choice (форма мотивирована Abney; (0,1]), см. docs/empirical-inventory.md. const HUE_PURITY_EXPONENT: f64 = 0.6; -/// Legacy-параметры формы нейтральной кривой. +/// Унаследованные параметры формы нейтральной кривой. /// -/// Значения сохраняют текущую эмиссию до замены construction policy, но не являются +/// Значения сохраняют текущую эмиссию до замены политики построения, но не являются /// универсальной психофизикой или пользовательскими осями намерения. Равномерные -/// Oklab/sRGB/CAM16-J′ шаги и прямое Whittle/crispening-прочтение уже опровергнуты -/// по магнитуде; тёмная ветвь дополнительно вырождается у near-black. Корневая -/// задача #219/#261 должна вывести контекстную progression либо удалить gamma- -/// параметризацию. Новые production-механизмы не должны зависеть от этих чисел. +/// шаги Oklab/sRGB/CAM16-J′ и прямое прочтение метрики контрастного усиления Уиттла +/// уже опровергнуты по магнитуде; тёмная ветвь дополнительно вырождается около +/// чёрного. Поэтому gamma — терминальная compatibility policy для прежних байтов, +/// а не human law. Задачи #219/#261 могут исследовать отдельную человеко- +/// ориентированную замену; generic fact-only механизмы не должны зависеть от +/// этих чисел или ждать такого исследования. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CurveParams { pub gamma_light: f64, @@ -81,14 +83,17 @@ impl Default for CurveParams { Self { // Показатель степени, которым гамма-кривая отображает t на светлотный // интервал [светлый якорь, базовый], при t <= 0.5. - // SSOT-TRACKED — frozen legacy gamma; root progression model open in #219/#261. + // SSOT-TRACKED — (e) compatibility policy прежней эмиссии; + // научная замена отдельно остаётся OPEN в #219/#261. gamma_light: 1.75, // Показатель степени для тёмной ветви (t > 0.5): интервал [базовый, // тёмный якорь]. - // SSOT-TRACKED — frozen legacy gamma; root progression model open in #219/#261. + // SSOT-TRACKED — (e) compatibility policy прежней эмиссии; + // научная замена отдельно остаётся OPEN в #219/#261. gamma_dark: 1.5, // Позиция вдоль параметра кривой t, где огибающая хромы достигает пика. - // SSOT-TRACKED — frozen legacy scalar; context hue-function research belongs to #219/#261. + // SSOT-TRACKED — зафиксированный скаляр прежней эмиссии; исследование + // зависимости от оттенка принадлежит #219/#261. chroma_peak_t: 0.35, } } @@ -816,7 +821,8 @@ mod wave2_e_locks { .fold(0.0_f64, f64::max) } - /// (e) DESIGN-CHOICE sensitivity-лок для трёх форм-параметров `CurveParams`. + /// (e) sensitivity-лок для трёх форм-параметров `CurveParams`; gamma — + /// compatibility policy, а не универсальная design/human axis. /// Свип каждой ручки по легальной полосе на дефолтной near-серой шкале: /// gamma_light/gamma_dark материальны (>1 JND), chroma_peak_t почти /// нематериален на near-серой шкале (< ½ JND), но был бы материальнее на @@ -860,8 +866,9 @@ mod wave2_e_locks { } // ───────────────────────────────────────────────────────────────────────────── -// Задача #108: активная гипотеза co-dependency для gamma_light/gamma_dark — -// crispening-метрика Уиттла (Whittle 1992 Vision Research 32(8):1493-1507, +// Задача #108: четвёртая проверенная и опровергнутая скалярная гипотеза для +// gamma_light/gamma_dark — метрика контрастного усиления Уиттла +// (Whittle 1992 Vision Research 32(8):1493-1507, // DOI 10.1016/0042-6989(92)90205-w; порогово-дискриминационная, НЕ appearance- // модель CAM16 — см. docs/empirical-inventory.md "требует параметр-free // метрики" / "не appearance-модель CAM16"). Три предыдущих скалярных метрики diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs new file mode 100644 index 00000000..22504572 --- /dev/null +++ b/crates/labcolors-core/src/numerics.rs @@ -0,0 +1,394 @@ +//! Типизированные решения для branch-sensitive численных sites (#281). +//! +//! Diagnostic `f64` не становится semantic verdict сам по себе. Determinate +//! разрешён только вместе с объявленной гарантией; при отсутствии sound bound +//! или пересечении outward-интервала с границей возвращается `Indeterminate`. + +/// Stable outcomes admitted for a migrated branch-sensitive site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum StableNumericalOutcomeV1 { + /// Determinate branch follows from exact finite/integer/rational evidence. + BitExact, + /// No semantic branch is selected. + Indeterminate, +} + +impl StableNumericalOutcomeV1 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + Self::Indeterminate => "indeterminate", + } + } +} + +/// Sound-bound availability for a migrated numerical site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalBoundStatusV1 { + /// No sound bound has been admitted. + Unavailable, +} + +impl NumericalBoundStatusV1 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + } + } +} + +/// Whether a stable profile may silently choose another decision path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalFallbackStatusV1 { + /// No fallback; compatibility requires an explicit profile. + None, +} + +impl NumericalFallbackStatusV1 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::None => "none", + } + } +} + +/// Machine-readable registry row required by research lock #281. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct NumericalSiteRecordV1 { + /// Stable site identity. + pub site_id: NumericalSiteIdV1, + /// Branch-sensitive operations. + pub operations: &'static str, + /// Input/output domain. + pub domain: &'static str, + /// Semantic branch affected by the value. + pub branch_effect: &'static str, + /// Lawful stable outcomes. + pub stable_outcomes: &'static [StableNumericalOutcomeV1], + /// Sound-bound availability. + pub bound_status: NumericalBoundStatusV1, + /// Executable boundary corpus identifiers. + pub boundary_corpus: &'static str, + /// Required cross-runtime comparison scope. + pub runtime_matrix: &'static str, + /// Fallback status. + pub fallback_status: NumericalFallbackStatusV1, + /// Explicit compatibility profile, if one exists. + pub legacy_profile: Option<&'static str>, +} + +// Enum identity and its registry row are emitted by one declaration. A new +// migrated site therefore cannot exist without machine-readable metadata. +macro_rules! define_numerical_registry_v1 { + ($( + $(#[$variant_meta:meta])* + $variant:ident => { + key: $key:literal, + operations: $operations:literal, + domain: $domain:literal, + branch_effect: $branch_effect:literal, + stable_outcomes: [$($stable_outcome:path),+ $(,)?], + bound_status: $bound_status:path, + boundary_corpus: $boundary_corpus:literal, + runtime_matrix: $runtime_matrix:literal, + fallback_status: $fallback_status:path, + legacy_profile: $legacy_profile:expr $(,)? + } + ),+ $(,)?) => { + /// Зарегистрированный migrated site, где число влияет на semantic branch. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[non_exhaustive] + pub enum NumericalSiteIdV1 { + $($(#[$variant_meta])* $variant),+ + } + + impl NumericalSiteIdV1 { + /// Стабильный wire/registry key. + pub fn key(self) -> &'static str { + match self { + $(Self::$variant => $key),+ + } + } + } + + const NUMERICAL_REGISTRY_V1: &[NumericalSiteRecordV1] = &[ + $(NumericalSiteRecordV1 { + site_id: NumericalSiteIdV1::$variant, + operations: $operations, + domain: $domain, + branch_effect: $branch_effect, + stable_outcomes: &[$($stable_outcome),+], + bound_status: $bound_status, + boundary_corpus: $boundary_corpus, + runtime_matrix: $runtime_matrix, + fallback_status: $fallback_status, + legacy_profile: $legacy_profile, + }),+ + ]; + }; +} + +define_numerical_registry_v1! { + /// Glow: первый state, достигший target, либо глобальный максимум. + GlowTargetOrMaximumV1 => { + key: "glow-target-or-maximum-v1", + operations: "CAM16 forward powf; CAM16-UCS J-prime; abs; target >=; maximum ordering", + domain: "encoded sRGB8 point screen states -> diagnostic CAM16-UCS delta J-prime", + branch_effect: "first reached state versus global maximum and reached/unreachable status", + stable_outcomes: [ + StableNumericalOutcomeV1::BitExact, + StableNumericalOutcomeV1::Indeterminate, + ], + bound_status: NumericalBoundStatusV1::Unavailable, + boundary_corpus: "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", + runtime_matrix: "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", + fallback_status: NumericalFallbackStatusV1::None, + legacy_profile: Some(crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1.key()), + }, +} + +/// Registry уже мигрированных typed-decision sites V1. +/// +/// Это не заявление о завершённом аудите старых `f64` branches: его владелец — +/// #291. Новый site, переводимый на stable typed decision, обязан получить +/// строку до изменения runtime behavior. +pub fn numerical_registry_v1() -> &'static [NumericalSiteRecordV1] { + NUMERICAL_REGISTRY_V1 +} + +/// Доказанный outward-интервал, содержащий истинное значение. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OutwardIntervalV1 { + lower: f64, + upper: f64, +} + +impl OutwardIntervalV1 { + /// Создаёт конечный непустой интервал; перепутанные/NaN bounds — ошибка. + pub fn try_new(lower: f64, upper: f64) -> Result { + if !lower.is_finite() || !upper.is_finite() || lower > upper { + return Err(format!( + "outward interval вне домена: lower={lower}, upper={upper}" + )); + } + Ok(Self { lower, upper }) + } + + /// Нижняя доказанная граница. + pub fn lower(self) -> f64 { + self.lower + } + + /// Верхняя доказанная граница. + pub fn upper(self) -> f64 { + self.upper + } +} + +/// Неразделимое evidence, почему stable semantic decision нельзя принять. +/// +/// Вариант владеет своим доказательством: невозможно сконструировать +/// `IntervalOverlap` без interval или приписать outward bound отсутствующему +/// sound model. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum NumericalIndeterminacyV1 { + /// Для используемого transcendental backend нет sound error bound. + SoundBoundUnavailable, + /// Доказанный interval пересекает semantic boundary. + IntervalOverlap(OutwardIntervalV1), +} + +impl NumericalIndeterminacyV1 { + /// Стабильный wire key причины. + pub fn reason_key(self) -> &'static str { + match self { + Self::SoundBoundUnavailable => "sound-bound-unavailable", + Self::IntervalOverlap(_) => "interval-overlap", + } + } +} + +/// Класс доказательства determinate-решения. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum DecisionGuaranteeV1 { + /// Решение следует только из exact integer/rational state. + BitExact, + /// Решение следует из непересекающегося outward-интервала. + OutwardIntervalV1(OutwardIntervalV1), + /// Явно выбранный прежний platform/libm-dependent путь. + LegacyPlatformDependentV1, +} + +impl DecisionGuaranteeV1 { + /// Стабильный wire key. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + Self::OutwardIntervalV1(_) => "outward-interval-v1", + Self::LegacyPlatformDependentV1 => "legacy-platform-dependent-v1", + } + } + + /// Доказанный interval, если именно он является certificate решения. + pub fn interval(self) -> Option { + match self { + Self::OutwardIntervalV1(interval) => Some(interval), + Self::BitExact | Self::LegacyPlatformDependentV1 => None, + } + } +} + +/// Semantic result с явным numerical proof class. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum NumericalDecisionV1 { + /// Решение принято под указанной гарантией. + Determinate { + /// Предметный результат. + value: T, + /// Почему branch считается доказанным в объявленном профиле. + guarantee: DecisionGuaranteeV1, + }, + /// Stable branch не выбран. + Indeterminate { + /// Зарегистрированный site. + site_id: NumericalSiteIdV1, + /// Причина и её неразделимое numerical evidence. + evidence: NumericalIndeterminacyV1, + }, +} + +/// Результат проверки контракта `value >= target`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AtLeastDecisionV1 { + /// Весь interval лежит ниже target. + Below, + /// Весь interval держит target. + Meets, +} + +/// Классифицирует `value >= target` только по доказанному outward-интервалу. +pub fn classify_at_least_v1( + site_id: NumericalSiteIdV1, + interval: OutwardIntervalV1, + target: f64, +) -> Result, String> { + if !target.is_finite() { + return Err(format!("target не конечен: {target}")); + } + if interval.lower() >= target { + return Ok(NumericalDecisionV1::Determinate { + value: AtLeastDecisionV1::Meets, + guarantee: DecisionGuaranteeV1::OutwardIntervalV1(interval), + }); + } + if interval.upper() < target { + return Ok(NumericalDecisionV1::Determinate { + value: AtLeastDecisionV1::Below, + guarantee: DecisionGuaranteeV1::OutwardIntervalV1(interval), + }); + } + Ok(NumericalDecisionV1::Indeterminate { + site_id, + evidence: NumericalIndeterminacyV1::IntervalOverlap(interval), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_overlap_never_becomes_a_tie_break() { + let site = NumericalSiteIdV1::GlowTargetOrMaximumV1; + let overlap = OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); + assert!(matches!( + classify_at_least_v1(site, overlap, 1.0).unwrap(), + NumericalDecisionV1::Indeterminate { + evidence: NumericalIndeterminacyV1::IntervalOverlap(_), + .. + } + )); + + let exact_target = OutwardIntervalV1::try_new(1.0, 1.0).unwrap(); + assert!(matches!( + classify_at_least_v1(site, exact_target, 1.0).unwrap(), + NumericalDecisionV1::Determinate { + value: AtLeastDecisionV1::Meets, + .. + } + )); + let below = OutwardIntervalV1::try_new(0.0, f64::from_bits(1.0_f64.to_bits() - 1)).unwrap(); + let below_decision = classify_at_least_v1(site, below, 1.0).unwrap(); + assert!(matches!( + below_decision, + NumericalDecisionV1::Determinate { + value: AtLeastDecisionV1::Below, + guarantee: DecisionGuaranteeV1::OutwardIntervalV1(certificate), + } if certificate == below + )); + + let touches_from_below = OutwardIntervalV1::try_new(0.9, 1.0).unwrap(); + assert!(matches!( + classify_at_least_v1(site, touches_from_below, 1.0).unwrap(), + NumericalDecisionV1::Indeterminate { + evidence: NumericalIndeterminacyV1::IntervalOverlap(certificate), + .. + } if certificate == touches_from_below + )); + } + + #[test] + fn invalid_intervals_and_targets_are_rejected_without_normalisation() { + assert!(OutwardIntervalV1::try_new(2.0, 1.0).is_err()); + assert!(OutwardIntervalV1::try_new(f64::NAN, 1.0).is_err()); + let valid = OutwardIntervalV1::try_new(0.0, 1.0).unwrap(); + assert!( + classify_at_least_v1(NumericalSiteIdV1::GlowTargetOrMaximumV1, valid, f64::NAN,) + .is_err() + ); + } + + #[test] + fn migrated_registry_is_non_vacuous_unique_and_covers_glow_site() { + let rows = numerical_registry_v1(); + assert!(!rows.is_empty()); + assert!(rows.iter().any(|row| { + row.site_id == NumericalSiteIdV1::GlowTargetOrMaximumV1 + && row.stable_outcomes + == [ + StableNumericalOutcomeV1::BitExact, + StableNumericalOutcomeV1::Indeterminate, + ] + && row.bound_status == NumericalBoundStatusV1::Unavailable + && row.fallback_status == NumericalFallbackStatusV1::None + && row.legacy_profile + == Some(crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1.key()) + })); + for (index, row) in rows.iter().enumerate() { + assert!(!row.operations.is_empty()); + assert!(!row.domain.is_empty()); + assert!(!row.branch_effect.is_empty()); + assert!(!row.stable_outcomes.is_empty()); + assert!(!row.boundary_corpus.is_empty()); + assert!(!row.runtime_matrix.is_empty()); + assert!( + rows[..index] + .iter() + .all(|previous| previous.site_id != row.site_id), + "duplicate numerical site: {}", + row.site_id.key() + ); + } + } +} diff --git a/crates/labcolors-core/src/pair.rs b/crates/labcolors-core/src/pair.rs index b43395bd..98569c60 100644 --- a/crates/labcolors-core/src/pair.rs +++ b/crates/labcolors-core/src/pair.rs @@ -581,15 +581,15 @@ mod level3_locks { for step in 0..=120 { let l = 0.02 + 0.96 * f64::from(step) / 120.0; let side = pair_side(swatch(l, h, rel_c)); - if let Some(p) = prev - && side != p - { - switches += 1; - assert_eq!( - (p, side), - (PairSide::Light, PairSide::Ink), - "hue {hue_step}, C_rel {rel_c}: только Light→Ink" - ); + if let Some(p) = prev { + if side != p { + switches += 1; + assert_eq!( + (p, side), + (PairSide::Light, PairSide::Ink), + "hue {hue_step}, C_rel {rel_c}: только Light→Ink" + ); + } } prev = Some(side); } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 3a556654..e4cce41f 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -190,12 +190,16 @@ const DECORATIVE_FLOOR_MIN: f64 = 7.5; /// меняет ни одного реального эмитируемого контраста. Ре-аудит /// `science/reclassify-e-buckets` 2026-07-07 — реестр /// docs/empirical-inventory.md. +// Rust 1.85 не считает использованием обращение только из const-assert и тестов; +// это разложение provenance, а не отдельная production-политика. +#[allow(dead_code)] // SSOT-TRACKED — квант-guard декоративного пола (issue #44), терминал (c) interval-insensitive, см. docs/empirical-inventory.md. const QUANT_GUARD: f64 = 0.2; // Компайл-тайм пиннинг деривации: DECORATIVE_FLOOR_MIN == MODEL_LC_FLOOR + -// QUANT_GUARD в пределах f64-шума суммирования. Заодно ИСПОЛЬЗУЕТ обе константы в -// продакшене (не dead_code); сам литерал 7.5 при этом НЕ меняется (байт-идентичность). +// QUANT_GUARD в пределах f64-шума суммирования. Это compile-time-фиксация +// provenance; сам поставляемый литерал 7.5 при этом НЕ меняется +// (байт-идентичность). const _: () = { let derived = crate::lpc::MODEL_LC_FLOOR + QUANT_GUARD; let d = DECORATIVE_FLOOR_MIN - derived; @@ -667,6 +671,8 @@ pub enum RoleSpec { tint: crate::ladder::LadderTint, /// Контрактная ступень стека. step: crate::glow::GlowStep, + /// Явный профиль численного решения из клиентского контракта. + decision_profile: crate::glow::GlowDecisionProfileV1, }, /// Заливка пары ([`crate::pair`]): якорь источника, сдвинутый до победы /// перцептивной стороны лейбла в штатной полярности; солид-эмиссия. @@ -735,8 +741,9 @@ pub enum RoleSpec { /// Резолв — [`Resolved::Material`] ([`crate::material`], #89). /// /// Тон строится dj-anchor-солвером на светлоте `tone` от фона резолва в - /// оттенке семьи; α выводится как минимальная плотность, при которой композит - /// над худшим фоном коридора держит `floor` (читаемость коммит-полюса). + /// оттенке семьи; α выбирается охарактеризованным для платформы поиском + /// с фиксированным числом шагов и повторно проверяется как проходящее + /// состояние для `floor`. Material { /// Оттенок семьи тона. `None` — нейтральный материал (подтон таблицы, то /// же 286°, что и остальные нейтральные эмиссии). `Some(tint)` — @@ -1560,12 +1567,14 @@ impl Default for RoleTable { } } -/// The outcome of resolving one role: a solved colour, an honest zero, or a -/// principled reason it is unreachable on this background. +/// The outcome of resolving one role: a solved colour, an honest zero, a typed +/// numerical indeterminacy, or a failure reason. /// -/// Unreachability is surfaced per role, never masked — a role on an extreme +/// Physical unreachability is surfaced per role, never masked — a role on an extreme /// background (e.g. muted text on a mid-grey that cannot supply enough contrast) /// returns [`Unreachable`], it is not silently clipped to a wrong colour. +/// [`Unreachable::InternalInvariant`] has different provenance: bindings must +/// turn it into a whole-call internal/incompatible-contract error. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum Resolved { @@ -1598,13 +1607,18 @@ pub enum Resolved { /// Свечение: screen-слои (core, halo) + решённая интенсивность /// (labui ADR-0002 §5). Потребитель красит слои с `mix-blend-mode: screen`. Glow(GlowResolved), + /// Стабильный запрос Glow, для которого отсутствует sound численная граница: + /// семантический победитель не выбран, CSS-эмиссия отсутствует. + GlowIndeterminate(GlowIndeterminateResolved), /// Двухслойный материал (стекло/акрил): полупрозрачный тинт `01` + опаковая /// база `02`, обе — один тон, с ВЫВЕДЕННОЙ альфой (композит-гарантия над /// коридором фонов). См. [`MaterialResolved`] и [`crate::material`]. Material(MaterialResolved), /// The honest zero of the `Role::None` token: no colour, no contrast. None, - /// No colour can satisfy this role against this background, with the reason. + /// A physical/domain failure, or an internal core invariant failure. Bindings + /// project only physical variants as per-role outcomes and fail the whole + /// call closed for [`Unreachable::InternalInvariant`]. Unreachable(Unreachable), } @@ -1613,8 +1627,9 @@ pub enum Resolved { /// /// Потребитель красит `--lab-{role}: rgba(tint, α)` — браузер композитит на /// фактической подложке. `composite` — то, во что этот rgba складывается на -/// ФОНЕ РЕЗОЛВА (`α·tint + (1−α)·bg`, кодированный sRGB — device-пространство -/// Figma/браузера); его контраст ([`TranslucentResolved::composite_lc`], +/// ФОНЕ РЕЗОЛВА (`α·tint + (1−α)·bg`) в объявленном encoded-sRGB8 reference- +/// профиле, заземлённом Figma-якорями, но не выдаваемом за универсальный +/// браузерный pipeline; его контраст ([`TranslucentResolved::composite_lc`], /// [`composite_wcag`](TranslucentResolved::composite_wcag)) — то, что фаза 1 AA меряет /// (контраст полупрозрачной роли определён её композитом, не тинтом). На ином /// фоне композит другой — это и есть смысл альфы; гарантия сформулирована для @@ -1623,8 +1638,8 @@ pub enum Resolved { pub struct TranslucentResolved { /// Тинт `#RRGGBB` — цвет, эмитируемый как `rgba(tint, α)` (без учёта α). tint_hex: String, - /// Фактическая α `(0, 1]` — запрошенная, если разрешима, иначе поднятая до - /// разрешимого минимума (для альфа-аналога; у прямой лестницы = альфа позиции). + /// Фактическая α `(0, 1]` — запрошенная, если существует точный байтовый тинт, + /// иначе первый проходящий `binary64` (у прямой лестницы = альфа позиции). alpha: f64, /// Солид-композит `rgba(tint, α)` над фоном резолва, `#RRGGBB`. composite_hex: String, @@ -1640,15 +1655,16 @@ pub struct TranslucentResolved { /// до этого флага такие тени/свечения проходили как валидный резолв /// молча). Параметр-свободный замер: сетка дисплея, не политика. composite_distinct: bool, - /// Запрошенная α была поднята до разрешимого минимума (`α_min`), потому что - /// исходная не воспроизводима в гамуте — честный флаг деградации КОНТРАКТА + /// Запрошенная α была поднята до первого разрешимого sRGB8-значения, потому + /// что ни один байтовый тинт не воспроизводит цель — честный флаг деградации + /// КОНТРАКТА /// РОЛИ (симметрия с `compressed`/`degraded`). Ставится только на пути /// альфа-аналога ([`resolve_rgba_inverted`]), где солид-цель фиксирована, а /// α выводится; у прямой лестницы ([`resolve_rgba_direct`]) всегда `false`. /// /// Цвет при этом НЕ врёт: композит фактической пары остаётся ПОБАЙТНО равен /// солиду (двигается только прозрачность — см. [`crate::alpha`]). Флаг лишь - /// объявляет, что эмитированная α — не запрошенная, а минимально разрешимая + /// объявляет, что эмитированная α — не запрошенная, а первая разрешимая /// ([`alpha`](Self::alpha) несёт фактическое значение). alpha_coerced: bool, /// Солидная семейная граница (`border--strong`, M2 ch5c) была @@ -1695,7 +1711,8 @@ impl TranslucentResolved { self.composite_distinct } - /// Запрошенная α была поднята до `α_min` (альфа-аналог с неразрешимой + /// Запрошенная α была поднята до первого проходящего sRGB8-значения + /// (альфа-аналог с неразрешимой /// запрошенной прозрачностью). `false` у прямой лестницы и когда /// запрошенная α разрешима как есть. Цвет композита при этом равен солиду /// побайтно — коэрсится только прозрачность (см. поле-документацию). @@ -1714,16 +1731,31 @@ impl TranslucentResolved { /// /// Слои — [`crate::glow::glow_layers_from_source`] (halo = источник, core = /// пересвет); α — [`crate::glow::solve_screen_alpha_for_dj`] под контрактную -/// ступень на фактическом фоне; `degraded` — честный флаг закона 2 ADR-0002 -/// (цель недостижима даже при α = 1, например на белом — screen гаснет -/// физически; возвращён ближайший достижимый шаг). +/// ступень на фактическом фоне; `degraded` — честный флаг закона 2 ADR-0002: +/// цель не держит ни одно достижимое sRGB8-состояние, поэтому возвращено глобально +/// лучшее состояние. На белом screen является точечным no-op только в объявленном +/// reference-профиле — это не утверждение о физическом свечении. Recipe, +/// appearance-диагностика, диагностика выбора и точный сертификат композита +/// возвращаются раздельно: ни одно из них не повышает силу другого. #[derive(Debug, Clone, PartialEq)] pub struct GlowResolved { core_hex: String, halo_hex: String, alpha: f64, - achieved_dj: f64, - degraded: bool, + alpha_css: String, + target_dj: f64, + halo_composite_hex: String, + halo_achieved_dj: f64, + core_composite_hex: String, + core_achieved_dj: f64, + target_status: crate::glow::GlowTargetStatus, + layer_recipe_profile: crate::glow::GlowLayerRecipeProfileV1, + appearance_diagnostic_profile: crate::glow::GlowDiagnosticProfileV1, + selection_diagnostic_profile: Option, + decision_profile: crate::glow::GlowDecisionProfileV1, + decision_guarantee: crate::numerics::DecisionGuaranteeV1, + halo_composite_certificate: crate::glow::GlowCompositeCertificateV1, + core_composite_certificate: crate::glow::GlowCompositeCertificateV1, } impl GlowResolved { @@ -1739,13 +1771,120 @@ impl GlowResolved { pub fn alpha(&self) -> f64 { self.alpha } - /// Фактический |ΔJ'| композита от фона (замер на эмитируемом hex). + /// Каноническая CSS-запись alpha с точным обратным чтением в тот же `f64`. + pub fn alpha_css(&self) -> &str { + &self.alpha_css + } + /// Целевой |ΔJ′| изолированного halo-композита. + pub fn target_dj(&self) -> f64 { + self.target_dj + } + /// Точный профиль композита, отдельный от гарантии диагностики или решения. + pub fn composite_profile(&self) -> crate::glow::GlowCompositeProfileV1 { + self.halo_composite_certificate.profile() + } + /// Версионированный профиль, построивший анатомию core/halo. + pub fn layer_recipe_profile(&self) -> crate::glow::GlowLayerRecipeProfileV1 { + self.layer_recipe_profile + } + /// Appearance-профиль полного результата Glow. Он всегда присутствует: даже + /// выбор точного no-op вычисляет `core_achieved_dj` через CAM16-UCS J′. + pub fn appearance_diagnostic_profile(&self) -> crate::glow::GlowDiagnosticProfileV1 { + self.appearance_diagnostic_profile + } + /// Диагностический профиль, участвовавший именно в выборе target/max. + /// У стабильного точного no-op его нет. + pub fn selection_diagnostic_profile(&self) -> Option { + self.selection_diagnostic_profile + } + /// Явно выбранный клиентский численный профиль. + pub fn decision_profile(&self) -> crate::glow::GlowDecisionProfileV1 { + self.decision_profile + } + /// Гарантия семантического решения target/max. + pub fn decision_guarantee(&self) -> crate::numerics::DecisionGuaranteeV1 { + self.decision_guarantee + } + /// Слой, по которому решалась целевая ступень. + pub fn constraint_layer(&self) -> crate::glow::GlowConstraintLayer { + crate::glow::GlowConstraintLayer::Halo + } + /// Типизированный исход проверки цели. + pub fn target_status(&self) -> crate::glow::GlowTargetStatus { + self.target_status + } + /// Reference-композит изолированного halo на фоне резолва. + pub fn halo_composite_hex(&self) -> &str { + &self.halo_composite_hex + } + /// Фактический |ΔJ′| изолированного halo-композита. + pub fn halo_achieved_dj(&self) -> f64 { + self.halo_achieved_dj + } + /// Reference-композит изолированного core на том же фоне и alpha. + pub fn core_composite_hex(&self) -> &str { + &self.core_composite_hex + } + /// Фактический |ΔJ′| изолированного core-композита. + pub fn core_achieved_dj(&self) -> f64 { + self.core_achieved_dj + } + /// Точное свидетельство halo-композита. + pub fn halo_composite_certificate(&self) -> &crate::glow::GlowCompositeCertificateV1 { + &self.halo_composite_certificate + } + /// Точное свидетельство core-композита. + pub fn core_composite_certificate(&self) -> &crate::glow::GlowCompositeCertificateV1 { + &self.core_composite_certificate + } + /// Алиас совместимости: прежнее поле измеряло именно halo. pub fn achieved_dj(&self) -> f64 { - self.achieved_dj + self.halo_achieved_dj } - /// Цель недостижима — возвращён ближайший достижимый шаг (ADR-0002). + /// Алиас совместимости: `true` для точного и legacy-исходов недостижимости. pub fn degraded(&self) -> bool { - self.degraded + matches!( + self.target_status, + crate::glow::GlowTargetStatus::ExactNoopUnreachable + | crate::glow::GlowTargetStatus::LegacyUnreachable + ) + } +} + +/// Стабильный терминальный исход Glow при отсутствии sound-границы target/max. +#[derive(Debug, Clone, PartialEq)] +pub struct GlowIndeterminateResolved { + source_hex: String, + target_dj: f64, + decision_profile: crate::glow::GlowDecisionProfileV1, + site_id: crate::numerics::NumericalSiteIdV1, + evidence: crate::numerics::NumericalIndeterminacyV1, +} + +impl GlowIndeterminateResolved { + /// Канонический source/halo-якорь; он не эмитится без решения. + pub fn source_hex(&self) -> &str { + &self.source_hex + } + /// Запрошенный модуль диагностической цели. + pub fn target_dj(&self) -> f64 { + self.target_dj + } + /// Явный стабильный профиль из клиентского контракта. + pub fn decision_profile(&self) -> crate::glow::GlowDecisionProfileV1 { + self.decision_profile + } + /// Зарегистрированный чувствительный к ветвлению участок. + pub fn site_id(&self) -> crate::numerics::NumericalSiteIdV1 { + self.site_id + } + /// Неразделимая причина вместе с её sound-интервалом, если он существует. + pub fn evidence(&self) -> crate::numerics::NumericalIndeterminacyV1 { + self.evidence + } + /// Цель относится к точечному слою halo. + pub fn constraint_layer(&self) -> crate::glow::GlowConstraintLayer { + crate::glow::GlowConstraintLayer::Halo } } @@ -1759,19 +1898,21 @@ impl GlowResolved { /// [`base_hex`](Self::base_hex) и [`solid_hex`](Self::solid_hex) равны по /// построению (единственная решаемая величина — α). /// -/// Гарантия читаемости — свойство GLASS-режима (тинт над живым фоном): α выведена -/// как минимальная плотность, при которой коммит-полюс поверхности +/// Гарантия читаемости — свойство GLASS-режима (тинт над живым фоном): α — +/// повторно проверенный верхний кандидат, при котором коммит-полюс поверхности /// ([`pole`](Self::pole)) держит [`floor`](Self::floor) по всему коридору /// `[чёрный, белый]` ([`crate::material`]). [`worst_contrast`](Self::worst_contrast) /// и [`guaranteed`](Self::guaranteed) пересчитываемы потребителем из эмитированных -/// `01`/`02` (та же α-граничная математика, `material-guarantee.ts`). +/// `01`/`02`: ядро и официальный `packages/colors/effective-bg.js::compositeOver` +/// используют один byte-scale affine order `B + α·(T−B)`. #[derive(Debug, Clone, PartialEq)] pub struct MaterialResolved { tone_hex: String, alpha: f64, worst_contrast: f64, + alpha_guarantee: crate::material::MaterialAlphaGuaranteeV1, + alpha_status: crate::material::MaterialAlphaStatusV1, floor: f64, - guaranteed: bool, pole: crate::material::Pole, achieved_dj: f64, tone_compressed: bool, @@ -1798,7 +1939,7 @@ impl MaterialResolved { &self.tone_hex } - /// Выведенная альфа тинта `01`, `(0, 1]`. + /// Выбранная альфа тинта `01`, `[0, 1]`. pub fn alpha(&self) -> f64 { self.alpha } @@ -1809,16 +1950,26 @@ impl MaterialResolved { self.worst_contrast } - /// WCAG-пол читаемости, который держит выведенная α (напр. 4.5 / 3.0). + /// Численный класс выбора alpha-границы; точные утверждения композитора + /// намеренно отделены от этого охарактеризованного для платформы свидетельства. + pub fn alpha_guarantee(&self) -> crate::material::MaterialAlphaGuaranteeV1 { + self.alpha_guarantee + } + + /// Типизированный исход floor; недостижимость отделена от невалидного ввода. + pub fn alpha_status(&self) -> crate::material::MaterialAlphaStatusV1 { + self.alpha_status + } + + /// Запрошенный WCAG-пол (например 4.5 / 3.0). Он выполнен только при + /// [`MaterialAlphaStatusV1::Satisfied`](crate::material::MaterialAlphaStatusV1::Satisfied). pub fn floor(&self) -> f64 { self.floor } - /// Гарантия выполнена: `worst_contrast ≥ floor` (α нашлась в `(0, 1]`). - /// `false` — пол недостижим даже при α = 1 (тогда α = 1 как ближайшая - /// достижимая, честная деградация — не молчание). + /// Предикат совместимости поверх [`Self::alpha_status`]. pub fn guaranteed(&self) -> bool { - self.guaranteed + self.alpha_status == crate::material::MaterialAlphaStatusV1::Satisfied } /// Коммит-полюс поверхности: полюс максимального контраста на тоне (белый на @@ -1917,7 +2068,7 @@ impl Resolved { Resolved::Color { solved, .. } => Some(solved.lc()), Resolved::Translucent(r) => Some(r.composite_lc), // Свечение — не контраст-роль: его контракт — |ΔJ'| ступени, не Lc. - Resolved::Glow(_) => Option::None, + Resolved::Glow(_) | Resolved::GlowIndeterminate(_) => Option::None, // Материал — поверхность, не контраст-роль: его контракт — WCAG // α-гарантия читаемости + |ΔJ'| различимость тона, не единый Lc. Resolved::Material(_) => Option::None, @@ -2191,37 +2342,90 @@ fn resolve_spec_in( // держать юр. пол UI (3:1). Пол применим лишь к солиду — у // полупрозрачной позиции контраст определяется композитом, а не // тинтом, и притемнять тинт бессмысленно. - if let Some(floor) = floor - && (alpha - 1.0).abs() < f64::EPSILON - { - return resolve_solid_with_ui_floor(tint.for_vc(vc), floor, bg, vc, ctx); + if let Some(floor) = floor { + if (alpha - 1.0).abs() < f64::EPSILON { + return resolve_solid_with_ui_floor(tint.for_vc(vc), floor, bg, vc, ctx); + } } return resolve_rgba_direct(tint.for_vc(vc), alpha, bg, vc); } - RoleSpec::Glow { tint, step } => { + RoleSpec::Glow { + tint, + step, + decision_profile, + } => { // Свечение: halo = якорь источника по теме; core — пересвет; // интенсивность решается под контрактную ступень на фоне резолва. let halo_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(vc)); - let (core_hex, halo_hex) = match crate::glow::glow_layers_from_source(&halo_hex, vc) { - Ok(pair) => pair, - Err(e) => return Resolved::Unreachable(Unreachable::InvalidInput(e)), - }; let bg_hex = crate::spaces::srgb::hex_from_srgb_encoded(quantise_encoded(bg.encoded_display())); return match crate::glow::solve_screen_alpha_for_dj( &halo_hex, &bg_hex, step.target_dj(), + decision_profile, vc, ) { - Ok(g) => Resolved::Glow(GlowResolved { - core_hex, - halo_hex, - alpha: g.alpha, - achieved_dj: g.achieved_dj, - degraded: g.degraded, - }), - Err(e) => Resolved::Unreachable(Unreachable::InvalidInput(e)), + Ok(crate::numerics::NumericalDecisionV1::Indeterminate { site_id, evidence }) => { + Resolved::GlowIndeterminate(GlowIndeterminateResolved { + source_hex: halo_hex, + target_dj: step.target_dj(), + decision_profile, + site_id, + evidence, + }) + } + Ok(crate::numerics::NumericalDecisionV1::Determinate { + value: g, + guarantee, + }) => { + let (core_hex, halo_hex) = + match crate::glow::glow_layers_from_source(&halo_hex, vc) { + Ok(pair) => pair, + Err(e) => { + return Resolved::Unreachable(Unreachable::InternalInvariant( + format!("generated Glow layer recipe was rejected: {e}"), + )); + } + }; + let core_measurement = match crate::glow::measure_screen_layer_at_alpha( + &core_hex, + &bg_hex, + g.alpha(), + vc, + ) { + Ok(measurement) => measurement, + Err(e) => { + return Resolved::Unreachable(Unreachable::InternalInvariant(format!( + "generated Glow core measurement was rejected: {e}" + ))); + } + }; + Resolved::Glow(GlowResolved { + core_hex, + halo_hex, + alpha: g.alpha(), + alpha_css: g.alpha_css().to_string(), + target_dj: g.target_dj(), + halo_composite_hex: g.composite_hex().to_string(), + halo_achieved_dj: g.achieved_dj(), + core_composite_hex: core_measurement.composite_hex, + core_achieved_dj: core_measurement.achieved_dj, + target_status: g.status(), + layer_recipe_profile: + crate::glow::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1, + appearance_diagnostic_profile: + crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, + selection_diagnostic_profile: g.selection_diagnostic_profile(), + decision_profile, + decision_guarantee: guarantee, + halo_composite_certificate: g.composite_certificate().clone(), + core_composite_certificate: core_measurement.certificate, + }) + } + Err(e) => Resolved::Unreachable(Unreachable::InternalInvariant(format!( + "generated Glow solve request was rejected: {e}" + ))), }; } RoleSpec::AlphaAnalog { of, alpha } => { @@ -2293,8 +2497,9 @@ fn resolve_spec_in( /// Лестница: rgba(`tint`, `alpha`) эмитится напрямую; его композит на фоне /// резолва замеряется для контраста. `tint` — кодированный (byte/255) sRGB. /// -/// Композитинг straight-alpha живёт в device-пространстве (гамма-кодированный -/// sRGB) — тот же путь, что Figma/браузер ([`crate::alpha`]). Контраст меряется +/// Композитинг straight-alpha живёт в версионированном encoded-sRGB8 reference- +/// профиле, который воспроизводит измеренные Figma-якоря ([`crate::alpha`]), но +/// не сертифицирует любой pipeline браузера и управления цветом. Контраст меряется /// на КОМПОЗИТЕ (солид-эквивалент), не на тинте: контраст полупрозрачной роли /// определён тем, во что она складывается на подложке. /// Квантовать кодированный цвет до 8-битной сетки (hex-roundtrip без строки): @@ -2334,13 +2539,11 @@ fn resolve_rgba_direct( )); } let bg_encoded = bg.encoded_display(); - // Тинт квантуется ДО композита: наружу уходит 8-битный tint_hex, и браузер - // скомпозитит именно его — замер обязан считаться из эмитируемого значения, - // иначе composite_hex/Lc/WCAG расходились бы с CSS-результатом на LSB. + // Тинт квантуется ДО композита: сертификат и эмиссия обязаны ссылаться на + // один и тот же encoded-sRGB8 reference-пиксель. let tint_q = quantise_encoded(tint_encoded); - let composite = crate::alpha::composite_over_encoded(tint_q, alpha, bg_encoded); // Прямая лестница эмитит запрошенную α как есть — коэрсии нет по построению. - finish_rgba(tint_q, alpha, composite, bg_encoded, vc, false, false) + finish_rgba(tint_q, alpha, bg_encoded, vc, false, false) } /// Резолв ЦВЕТНОГО текст/UI-лейбла (ратификация ch5c, M1). @@ -2487,8 +2690,7 @@ fn resolve_solid_with_ui_floor( Ok(solved) => { let shifted = crate::spaces::srgb::srgb_encoded_from_hex(solved.hex()) .expect("hex собственного солвера всегда валиден"); - let composite = crate::alpha::composite_over_encoded(shifted, 1.0, bg_encoded); - finish_rgba(shifted, 1.0, composite, bg_encoded, vc, false, true) + finish_rgba(shifted, 1.0, bg_encoded, vc, false, true) } Err(reason) => Resolved::Unreachable(reason), } @@ -2523,17 +2725,18 @@ fn resolve_pair_label( } else { surface_alpha_light }; - // Тинт квантуется ДО композита — браузер красит 8-битный tint, поэтому - // подложка обязана считаться из отдаваемого значения (тот же закон, что в - // resolve_rgba_direct). Композит квантуется тем же форматтером: лейбл решается - // против ТОГО ЖЕ пикселя, что уйдёт наружу. + // Тинт квантуется ДО композита: подложка обязана считаться из отдаваемого + // значения в едином encoded-sRGB8 reference-домене. let tint_q = quantise_encoded(tint.for_vc(vc)); - let surface = quantise_encoded(crate::alpha::composite_over_encoded( - tint_q, - alpha, - bg.encoded_display(), - )); - let surface_hex = crate::spaces::srgb::hex_from_srgb_encoded(surface); + let surface_hex = + match crate::alpha::composite_hex_from_encoded(tint_q, alpha, bg.encoded_display()) { + Ok(hex) => hex, + Err(error) => { + return Resolved::Unreachable(Unreachable::InvalidInput(format!( + "тинт-поверхность бейджа вне encoded-sRGB8 reference-домена: {error}" + ))); + } + }; let Ok(surface_bg) = BgInput::solid(&surface_hex) else { // Композит 8-битных каналов всегда в кубе — недостижимо, но честнее // отказ, чем правдоподобный мусор (RoleSpec публичен). @@ -2549,19 +2752,17 @@ fn resolve_pair_label( } /// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва -/// инвертируется в `(tint, фактическая α)` через [`crate::alpha::resolve_alpha_analog`]. -/// Композит фактической пары равен солиду ПО ПОСТРОЕНИЮ, поэтому контраст -/// наследуется солидом; замер идёт на этом композите. `None`-инверсия -/// (вход вне гамута) физически недостижима — солид-тинт по теме всегда в гамуте. +/// инвертируется в `(tint, фактическая α)`. Перед инверсией цель квантуется до +/// эмитируемой sRGB8-сетки; production-композитор обязан побайтно вернуть её. fn resolve_rgba_inverted( solid_encoded: [f64; 3], requested_alpha: f64, bg: &BgInput, vc: &ViewingConditions, ) -> Resolved { - // Тот же домен-гард, что у прямого rgba-пути: RoleSpec публичен, а резолвер - // инверсии клампит запрошенную α — недоменная спека, собранная в обход - // валидатора конфига, стала бы правдоподобным hex вместо честного отказа. + // Тот же домен-гард, что у прямого rgba-пути: RoleSpec публичен. Без него + // недоменная спека, собранная в обход валидатора конфига, дошла бы до + // численного пути вместо честного типизированного исхода. if !rgba_input_valid(solid_encoded, requested_alpha) { return Resolved::Unreachable(Unreachable::InvalidInput( "alpha-analog-спека вне домена (солид [0,1], α (0,1]) — сборка в обход валидатора" @@ -2569,41 +2770,21 @@ fn resolve_rgba_inverted( )); } let bg_encoded = bg.encoded_display(); - let Some(analog) = - crate::alpha::resolve_alpha_analog(solid_encoded, requested_alpha, bg_encoded) - else { - // Тинт источника по теме — валидный кодированный цвет byte/255, поэтому - // домен инверсии не нарушается; None здесь означал бы мусорный вход. - return Resolved::Unreachable(Unreachable::InvalidInput( - "alpha-analog source out of encoded sRGB domain".to_string(), - )); - }; - // Тинт инверсии квантуется до композита (см. resolve_rgba_direct: замер из - // эмитируемого значения — браузер скомпозитит 8-битный tint_hex). Композит - // пересчитывается от квантованного тинта; равенство солиду держится в - // пределах LSB-границы квантования (#119). - let tint_q = quantise_encoded(analog.tint); - let composite = crate::alpha::composite_over_encoded(tint_q, analog.alpha, bg_encoded); - // Коэрсия α: фактическая (`analog.alpha`) строго выше запрошенной ⇔ пол - // `α_min` перекрыл запрошенную. `resolve_alpha_analog`: α = - // req.clamp(0,1).max(floor); под гардом `rgba_input_valid` (req ∈ (0,1]) - // clamp — no-op, т.е. α = req.max(floor). При floor ≤ req значения побайтно - // равны, при floor > req — строго больше. Сравнение точное, эпсилон не нужен: - // `max` возвращает либо тот же f64, либо floor. - let alpha_coerced = analog.alpha > requested_alpha; - finish_rgba( - tint_q, - analog.alpha, - composite, - bg_encoded, - vc, - alpha_coerced, - false, - ) + let solid_q = quantise_encoded(solid_encoded); + let analog = + match crate::alpha::resolve_alpha_analog_srgb8(solid_q, requested_alpha, bg_encoded) { + Ok(analog) => analog, + Err(error) => return Resolved::Unreachable(Unreachable::InvalidInput(error)), + }; + let (tint_srgb8, actual_alpha) = analog; + let tint_q = tint_srgb8.map(|channel| f64::from(channel) / 255.0); + // Резолвер возвращает тот же binary64 либо строго больший точный пол. + let alpha_coerced = actual_alpha > requested_alpha; + finish_rgba(tint_q, actual_alpha, bg_encoded, vc, alpha_coerced, false) } -/// Собрать [`Resolved::Translucent`] из тинта, альфы и композита: квантовать тинт и -/// композит до hex, замерить контраст композита против фона резолва. +/// Собрать [`Resolved::Translucent`] из эмитируемых тинта и альфы: вывести их +/// encoded-sRGB8 reference-композит и замерить его против фона резолва. /// /// Контраст меряется в тех же метриках, что и у солид-роли: перцептивный `Lc` /// на линейном свете ([`measure_contrast`]) и WCAG на кодированном дисплее — так @@ -2611,17 +2792,24 @@ fn resolve_rgba_inverted( fn finish_rgba( tint_encoded: [f64; 3], alpha: f64, - composite_encoded: [f64; 3], bg_encoded: [f64; 3], vc: &ViewingConditions, alpha_coerced: bool, floor_coerced: bool, ) -> Resolved { use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_gamma_inv}; - // Замер идёт по КВАНТОВАННОМУ композиту — тому же 8-битному hex, который - // уходит наружу (закон движка: честный dJ' меряется на отданном цвете, - // как в solve_dj; неквантованный замер расходился бы с отданным на LSB). - let composite_hex = hex_from_srgb_encoded(composite_encoded); + // Единый байтовый домен SSOT нужен и для hex, и для обеих метрик: + // нормализация `(byte/255)·255` способна изменить граничное значение + // половинного округления на один LSB. + let composite_hex = + match crate::alpha::composite_hex_from_encoded(tint_encoded, alpha, bg_encoded) { + Ok(hex) => hex, + Err(error) => { + return Resolved::Unreachable(Unreachable::InvalidInput(format!( + "rgba-композит вне encoded-sRGB8 reference-домена: {error}" + ))); + } + }; let composite_q = srgb_encoded_from_hex(&composite_hex).expect("hex собственного форматтера всегда валиден"); // Линейный свет из кодированного (per-channel gamma-декод) для перцептивного Lc. @@ -2636,9 +2824,9 @@ fn finish_rgba( let bg_linear = decode(bg_encoded); let (composite_lc, _) = measure_contrast(bg_linear, composite_linear, vc); let composite_wcag = crate::wcag::contrast_ratio(composite_q, bg_encoded); - // Отличимость на сетке дисплея (ADR-0002): сравнение по 8-битным hex — тому - // же представлению, в котором браузер отдаст пиксели. Фон квантуется тем же - // форматтером (off-grid фон, напр. blur-среднее, честно садится на байт). + // Отличимость в encoded-sRGB8 reference (ADR-0002): сравнение по тем же + // 8-битным hex, из которых строится сертификат. Фон квантуется тем же + // форматтером; применимость к рендереру проверяется отдельно (#241). let composite_distinct = composite_hex != hex_from_srgb_encoded(bg_encoded); Resolved::Translucent(TranslucentResolved { tint_hex: hex_from_srgb_encoded(tint_encoded), @@ -2657,9 +2845,9 @@ fn finish_rgba( /// /// Тон строится тем же dj-anchor-солвером, что декоративные |ΔJ'|-роли /// ([`resolve_dj`]), поэтому различимость поверхности от фона наследуется его -/// физикой. Альфа тинта выводится [`crate::material::solve_material_alpha_hex`] -/// как минимальная плотность, при которой композит тона над худшим фоном коридора -/// `[чёрный, белый]` держит пол читаемости коммит-полюса. `family_hued` — оттенок +/// физикой. Альфа тинта выбирается [`crate::material::solve_material_alpha_hex`] +/// как проходящий верхний кандидат, при котором композит тона над худшим фоном +/// коридора `[чёрный, белый]` держит пол. `family_hued` — оттенок /// семьи присутствует (флаг вырождения оттенка применим); у нейтрали `false`. fn resolve_material( bg: &BgInput, @@ -2690,10 +2878,15 @@ fn resolve_material( // Вырождение оттенка семьи у края гамута — только у семейных материалов; // нейтраль ахроматична намеренно, не «выродилась». let hue_vanished = family_hued && dj.solved.color().mp() < TINT_PERCEPTIBLE_MP_FLOOR; - // α: минимальная плотность под пол над коридором [чёрный, белый]. + // α: повторно проверенный проходящий верхний кандидат над коридором + // [чёрный, белый]. let m = match crate::material::solve_material_alpha_hex(&tone_hex, floor_ratio) { Ok(m) => m, - Err(e) => return Resolved::Unreachable(Unreachable::InvalidInput(e)), + Err(e) => { + return Resolved::Unreachable(Unreachable::InternalInvariant(format!( + "generated Material solve request was rejected: {e}" + ))); + } }; // Различимость солид-канона (= тона) от фона резолва на 8-битной сетке (тот же // замер, что у полупрозрачных ролей; off-grid фон честно квантуется). @@ -2701,11 +2894,12 @@ fn resolve_material( let distinct = tone_hex != bg_hex; Resolved::Material(MaterialResolved { tone_hex, - alpha: m.alpha, - worst_contrast: m.worst_contrast, + alpha: m.alpha(), + worst_contrast: m.worst_contrast(), + alpha_guarantee: m.guarantee(), + alpha_status: m.status(), floor: floor_ratio, - guaranteed: !m.degraded, - pole: m.pole, + pole: m.pole(), achieved_dj: dj.achieved_dj, tone_compressed: dj.degraded, hue_vanished, @@ -2961,9 +3155,10 @@ impl NamedRoleTable { } } - /// Алиасы `(имя, цель)` — эмитируются потребителем как CSS-ссылка - /// `--lab-{имя}: var(--lab-{цель})` (одна истина значения, ноль копий); - /// без переноса сюда алиасные роли контракта терялись бы при компиляции. + /// Алиасы `(имя, цель)` сохраняются в скомпилированном контракте, чтобы + /// delivery boundary спроецировала resolved outcome цели под client-owned + /// именем алиаса. Алиас не запускает отдельный solve и не меняет физическое + /// значение; без переноса сюда алиасные роли терялись бы при компиляции. pub fn aliases(&self) -> &[(String, String)] { &self.aliases } @@ -3590,6 +3785,78 @@ fn bg_display(bg: &BgInput) -> [f64; 3] { mod tests { use super::*; + fn one_glow_table( + source_hex: &str, + decision_profile: crate::glow::GlowDecisionProfileV1, + ) -> NamedRoleTable { + let source = crate::spaces::srgb::srgb_encoded_from_hex(source_hex).unwrap(); + NamedRoleTable::new( + vec![( + "opaque-client-id".to_string(), + RoleSpec::Glow { + tint: LadderTint::new([source; 4]).unwrap(), + step: crate::glow::GlowStep::Base, + decision_profile, + }, + )], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap() + } + + #[test] + fn full_glow_separates_recipe_appearance_and_selection_diagnostics() { + let vc = ViewingConditions::srgb(); + + let exact = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &one_glow_table("#4A8FFF", crate::glow::GlowDecisionProfileV1::StableV1), + &vc, + ); + let Resolved::Glow(exact) = &exact[0].1 else { + panic!("stable point no-op must resolve as a full Glow result"); + }; + assert_eq!( + exact.target_status(), + crate::glow::GlowTargetStatus::ExactNoopUnreachable + ); + assert_eq!( + exact.layer_recipe_profile().key(), + "cam16-jprime-oklab-cusp-v1" + ); + assert_eq!( + exact.appearance_diagnostic_profile(), + crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1 + ); + assert!(exact.selection_diagnostic_profile().is_none()); + + let legacy = resolve_named_set( + &BgInput::solid("#101012").unwrap(), + &one_glow_table( + "#4A8FFF", + crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1, + ), + &vc, + ); + let Resolved::Glow(legacy) = &legacy[0].1 else { + panic!("explicit legacy selection must resolve as a full Glow result"); + }; + assert_eq!( + legacy.selection_diagnostic_profile(), + Some(crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1) + ); + assert_eq!( + legacy.appearance_diagnostic_profile(), + crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1 + ); + assert!(matches!( + legacy.target_status(), + crate::glow::GlowTargetStatus::LegacyReached + | crate::glow::GlowTargetStatus::LegacyUnreachable + )); + } + // ───────────────────────────────────────────────────────────────────────── // DIFFERENTIAL HARNESS (perf/max-chroma-hotpath) — cusp-attracted hue. // @@ -5167,7 +5434,7 @@ mod tests { let set = resolve_set(&bg, &table, &vc); let no_silent_clip = set.iter().all(|(role, r)| match r { // Свечение не участвует в dJ'-клип-инварианте (не контраст-роль). - Resolved::Glow(_) => true, + Resolved::Glow(_) | Resolved::GlowIndeterminate(_) => true, Resolved::Color { solved, .. } => { if matches!(table.spec(*role), RoleSpec::DecorativeDj { .. }) { let jp_fg = crate::lcs::LcsColor::from_hex_with_vc(solved.hex(), &vc) @@ -5483,7 +5750,7 @@ mod tests { #[test] fn alpha_coerced_flags_only_when_requested_alpha_raised_to_floor() { - // H1 (аудит 2026-07-03): поднятие α до α_min на пути альфа-аналога — + // H1 (аудит 2026-07-03): поднятие α до byte-grid пола на пути альфа-аналога — // деградация КОНТРАКТА роли (эмитируется не запрошенная α). До флага она // проходила молча: композит побайтно равен солиду, но обещанная // прозрачность подменялась без объявления. Флаг делает подмену видимой. @@ -5530,6 +5797,16 @@ mod tests { t_ok.alpha() ); + // Разрешимость байтовой сетки сильнее непрерывной инверсии: белый красный + // тинт @ 0.12 уже округляется в #1F0000, поэтому коэрсии быть не должно. + let black = BgInput::solid("#000000").unwrap(); + let quantised_solid = srgb_encoded_from_hex("#1F0000").unwrap(); + let quantised = resolve_rgba_inverted(quantised_solid, 0.12, &black, &vc); + let t_quantised = quantised.translucent().unwrap(); + assert!(!t_quantised.alpha_coerced()); + assert_eq!(t_quantised.alpha().to_bits(), 0.12_f64.to_bits()); + assert_eq!(t_quantised.tint_hex(), "#FF0000"); + // Граница: α=1.0 всегда разрешима (тинт=солид) → флаг false даже для // насыщенного солида, который иначе коэрсил бы. let full = resolve_rgba_inverted(dark_solid, 1.0, &white, &vc); @@ -6247,6 +6524,7 @@ mod tests { // Дефолтная таблица не несёт Ladder/AlphaAnalog/Glow — недостижимо. Resolved::Translucent(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), Resolved::Glow(g) => format!("glow({},{})", g.halo_hex(), g.alpha()), + Resolved::GlowIndeterminate(_) => "GLOW_INDETERMINATE".to_string(), Resolved::Material(m) => { format!("material({},{:.4})", m.tint_hex(), m.alpha()) } diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index e9dddd33..b8d49c17 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -452,10 +452,10 @@ fn legalize_hue_among( /// Оттенок легален, если он на уровне пола или выше (где пол задан). Разведение от /// бренда Волной 1 УБРАНО — единственное исключение здесь категориальный пол. fn is_legal_hue(h: f64, floor: Option) -> bool { - if let Some(f) = floor - && normalize_hue(h) < f - { - return false; + if let Some(f) = floor { + if normalize_hue(h) < f { + return false; + } } true } @@ -473,11 +473,12 @@ fn is_legal_hue_among(h: f64, floor: Option, zones: &[NeighborZone]) -> boo /// (валидатор конфига и солвер) — дублированный блок разошёлся бы тихо, /// ровно как разошлись бы независимые литералы одного домена. fn check_hue_floor_domain(hue_floor: Option) -> Result<(), String> { - if let Some(floor) = hue_floor - && !(floor.is_finite() + if let Some(floor) = hue_floor { + if !(floor.is_finite() && (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&floor)) - { - return Err(format!("hue_floor вне домена [0, 360): {floor}")); + { + return Err(format!("hue_floor вне домена [0, 360): {floor}")); + } } Ok(()) } diff --git a/crates/labcolors-core/src/solve.rs b/crates/labcolors-core/src/solve.rs index 3741134e..f89d4af7 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -295,9 +295,10 @@ impl BgInput { } } - /// Гамма-кодированный 8-битный sRGB фона (`[0,1]³`, byte/255) — то самое - /// device-пространство, в котором Figma/браузер композитят straight-alpha - /// ([`crate::alpha`]). Альфа-роль ([`crate::semantic::RoleSpec::Ladder`] / + /// Гамма-кодированный 8-битный sRGB фона (`[0,1]³`, byte/255) — домен + /// reference-профиля [`crate::alpha`], заземлённого Figma-якорями без + /// универсального обещания browser pipeline. Альфа-роль + /// ([`crate::semantic::RoleSpec::Ladder`] / /// [`AlphaAnalog`](crate::semantic::RoleSpec::AlphaAnalog)) композитит свой /// тинт на этом фоне для честного замера контраста солид-эквивалента. Для /// [`Solid`](BgInput::Solid) это квантованный дисплей-цвет фона; будущие @@ -384,7 +385,9 @@ impl Solved { } } -/// Why a contract cannot be satisfied. Returned instead of silently clipping. +/// Why a solve could not return a colour. Physical/domain variants explain a +/// contract failure; [`Self::InternalInvariant`] reports core drift and must be +/// failed closed by bindings rather than projected as a physical outcome. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum Unreachable { @@ -418,6 +421,11 @@ pub enum Unreachable { GamutUnsupported, /// Malformed input, such as an invalid hex colour or a non-finite target. InvalidInput(String), + /// A value produced and validated by the core later violated an internal + /// postcondition. This is never client-input blame: bindings must fail the + /// enclosing call closed instead of projecting it as a physical role + /// outcome. + InternalInvariant(String), } impl core::fmt::Display for Unreachable { @@ -453,6 +461,7 @@ impl core::fmt::Display for Unreachable { ) } Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"), + Self::InternalInvariant(msg) => write!(f, "internal invariant failure: {msg}"), } } } @@ -1313,6 +1322,13 @@ mod tests { use super::*; use crate::lpc::lpc_with_vc; + #[test] + fn internal_invariant_failure_is_not_reported_as_invalid_client_input() { + let error = Unreachable::InternalInvariant("generated fixture drift".into()); + assert!(error.to_string().starts_with("internal invariant failure:")); + assert!(!error.to_string().starts_with("invalid input:")); + } + /// D2(a) страж (аудит 2026-07-03): `Unreachable` не несёт never-constructed /// вариантов-заготовок. `UnsupportedBackground` («future inputs», 0 точек /// конструирования по grep всех крейтов — только объявление + Display + wasm- @@ -1342,6 +1358,7 @@ mod tests { Unreachable::PolarityMismatch { target: 1.0 }, Unreachable::GamutUnsupported, Unreachable::InvalidInput("x".to_string()), + Unreachable::InternalInvariant("x".to_string()), ]; for u in &samples { assert!(!u.to_string().is_empty(), "Display пуст для {u:?}"); @@ -1354,7 +1371,8 @@ mod tests { | Unreachable::FloorUnreachable { .. } | Unreachable::PolarityMismatch { .. } | Unreachable::GamutUnsupported - | Unreachable::InvalidInput(_) => {} + | Unreachable::InvalidInput(_) + | Unreachable::InternalInvariant(_) => {} } } } @@ -2613,6 +2631,7 @@ mod tests { // Дефолтная таблица не несёт Ladder/AlphaAnalog/Glow — недостижимо здесь. Resolved::Translucent(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), Resolved::Glow(g) => format!("glow({},{})", g.halo_hex(), g.alpha()), + Resolved::GlowIndeterminate(_) => "glow-indeterminate".to_string(), Resolved::Material(m) => format!("material({},{:.4})", m.tint_hex(), m.alpha()), Resolved::None => "none".to_string(), Resolved::Unreachable(_) => "unreach".to_string(), diff --git a/crates/labcolors-core/src/spaces/oklch.rs b/crates/labcolors-core/src/spaces/oklch.rs index bfd8f2c4..da4c2bed 100644 --- a/crates/labcolors-core/src/spaces/oklch.rs +++ b/crates/labcolors-core/src/spaces/oklch.rs @@ -8,8 +8,11 @@ //! //! Значения остаются решёнными в sRGB-гамуте: oklch здесь — система координат //! записи, не расширение гамута. Точность цифр подобрана под БАЙТ-ТОЧНЫЙ -//! round-trip: `oklch_css_from_hex` → парс → sRGB даёт исходные 8-битные -//! байты на всём кубе (доказательство — тест `round_trip_is_byte_exact_*`). +//! round-trip: `oklch_css_from_hex` → независимый парс и обратные матрицы → +//! sRGB8. Исполняемый тест проверяет детерминированную решётку с шагом 5 по +//! каждому каналу (около 140 тысяч цветов, включая края) и весь серый ramp. +//! Это сильная выборочная проверка, но не полный перебор 16 777 216 входов; +//! переносимость за пределы проверенных target/toolchain она не объявляет. use super::oklab::srgb_linear_to_oklab; use super::srgb::srgb_from_hex; @@ -35,15 +38,16 @@ pub fn oklch_from_hex(hex: &str) -> Result<[f64; 3], String> { /// CSS-строка `oklch(L% C H)` / `oklch(L% C H / A)` из hex-солида и /// опциональной альфы. /// -/// Точность: L — 5 знаков процента, C — 6, H — 3; запас относительно -/// полушага 8-битного канала, доказан round-trip тестом на всём кубе. -/// Альфа — до 4 знаков с обрезкой хвостовых нулей: литералы рампы проходят -/// как есть (`0.122` → `0.122`), а вычисленная альфа (альфа-аналог) не -/// тащит float-хвост в CSS-строку. +/// Точность: L — 5 знаков процента, C — 6, H — 3. Байт-точность проверяется +/// исполняемой решёткой с шагом 5 и полным серым ramp. Полный sRGB8-куб пока не +/// заявляется доказанным: выборочный тест нельзя переименовывать в exhaustive. +/// Альфа печатается кратчайшей десятичной записью, которая восстанавливает +/// исходный `f64`. Вычисленная прозрачность участвует в сертификате композита, +/// поэтому произвольное число знаков после запятой здесь недопустимо. /// /// # Errors /// -/// `Err` — невалидный hex. +/// `Err` — невалидный hex, не-конечная альфа либо альфа вне `[0, 1]`. pub fn oklch_css_from_hex(hex: &str, alpha: Option) -> Result { let [l, c, h] = oklch_from_hex(hex)?; let base = format!("oklch({:.5}% {:.6} {:.3}", l * 100.0, c, h); @@ -53,27 +57,37 @@ pub fn oklch_css_from_hex(hex: &str, alpha: Option) -> Result) -> Result { match alpha { None => Ok(String::new()), - Some(a) => { - const ALPHA_NOISE: f64 = 1e-6; - if !a.is_finite() || !(-ALPHA_NOISE..=1.0 + ALPHA_NOISE).contains(&a) { - return Err(format!("альфа вне [0, 1]: {a}")); - } - let a4 = format!("{:.4}", a.clamp(0.0, 1.0)); - let a4 = a4.trim_end_matches('0').trim_end_matches('.'); - Ok(format!(" / {a4}")) - } + Some(a) => Ok(format!(" / {}", css_alpha_value(a)?)), } } +/// Каноническая CSS-запись альфы без потери binary64-точности. +/// +/// Сериализатор не исправляет значения вызывающего: даже малое отклонение за +/// `[0, 1]` означает, что сертификат был построен в другом домене. Знаковый +/// ноль нормализуется, потому что `-0` и `0` задают одну прозрачность, но первая +/// форма создаёт ненужный платформенный дрейф строк. +/// +/// # Errors +/// +/// `Err`, если значение не конечно или лежит вне `[0, 1]`. +pub fn css_alpha_value(alpha: f64) -> Result { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("альфа вне [0, 1]: {alpha}")); + } + let canonical = if alpha == 0.0 { 0.0 } else { alpha }; + Ok(canonical.to_string()) +} + /// Обратный путь (только для доказательства round-trip): (L, C, H°) → hex. #[cfg(test)] fn hex_from_oklch(l: f64, c: f64, h_deg: f64) -> String { @@ -86,8 +100,9 @@ fn hex_from_oklch(l: f64, c: f64, h_deg: f64) -> String { mod tests { use super::*; - /// Парсер эмитированной строки — эталонная реконструкция потребителя - /// (браузер парсит те же компоненты тем же путём Oklab → sRGB). + /// Независимая test-транскрипция CSS Color 4 Oklab → sRGB. Она проверяет + /// численный контракт эмиттера; паритет конкретного браузера — отдельный + /// WPT/headless gate. fn parse_emitted(css: &str) -> (String, Option) { let inner = css .strip_prefix("oklch(") @@ -144,24 +159,41 @@ mod tests { } } - /// Гард альфы: NaN — честная ошибка, конечный шум за краями — кламп - /// без артефакта "-0". + /// Вычисленная альфа является частью сертификата композита: CSS-строка + /// обязана восстанавливать то же binary64-значение, а не соседнюю точку. #[test] - fn alpha_guard_rejects_nan_and_clamps_noise() { + fn computed_alpha_round_trips_without_precision_loss() { + let alpha = 0.036_045_459_685_627_89_f64; + let css = oklch_css_from_hex("#4A8FFF", Some(alpha)).unwrap(); + let (_, parsed) = parse_emitted(&css); + assert_eq!( + parsed.unwrap().to_bits(), + alpha.to_bits(), + "CSS потерял точность вычисленной альфы: {css}" + ); + } + + /// Граница альфы не маскирует ошибку вычисления клампом; знаковый ноль + /// канонизируется без изменения значения прозрачности. + #[test] + fn alpha_guard_rejects_every_out_of_domain_value() { assert!(oklch_css_from_hex("#101012", Some(f64::NAN)).is_err()); assert!(oklch_css_from_hex("#101012", Some(f64::INFINITY)).is_err()); - // Грубо невалидная альфа — дефект вызывающего, не шум: ошибка, не кламп. + assert!(oklch_css_from_hex("#101012", Some(f64::NEG_INFINITY)).is_err()); assert!(oklch_css_from_hex("#101012", Some(-10.0)).is_err()); assert!(oklch_css_from_hex("#101012", Some(2.0)).is_err()); + assert!(oklch_css_from_hex("#101012", Some(-f64::EPSILON)).is_err()); + assert!(oklch_css_from_hex("#101012", Some(1.0 + f64::EPSILON)).is_err()); // Знаковый ноль H не просачивается в печать. assert!( !oklch_css_from_hex("#FFFFFF", None).unwrap().contains("-0."), "signed zero в компонентах запрещён" ); - let noisy = oklch_css_from_hex("#101012", Some(-1e-7)).unwrap(); - assert!(noisy.ends_with(" / 0)"), "шум у нуля клампится: {noisy}"); - let over = oklch_css_from_hex("#101012", Some(1.0 + 1e-9)).unwrap(); - assert!(over.ends_with(" / 1)"), "шум у единицы клампится: {over}"); + let zero = oklch_css_from_hex("#101012", Some(-0.0)).unwrap(); + assert!( + zero.ends_with(" / 0)"), + "знаковый ноль нормализован: {zero}" + ); } /// Форма строки — контракт потребителя: процент у L, слэш-альфа, diff --git a/crates/labcolors-core/src/spaces/p3.rs b/crates/labcolors-core/src/spaces/p3.rs index e79ab2ae..1d798e8d 100644 --- a/crates/labcolors-core/src/spaces/p3.rs +++ b/crates/labcolors-core/src/spaces/p3.rs @@ -28,8 +28,9 @@ //! [`super::srgb::srgb_gamma_inv`], вторых копий кривой нет. //! //! Точность цифр подобрана под БАЙТ-ТОЧНЫЙ round-trip: `p3_css_from_hex` → -//! парс → XYZ → sRGB даёт исходные 8-битные байты на решётке всего куба -//! (доказательство — тест `round_trip_is_byte_exact_on_lattice`). +//! парс → XYZ → sRGB даёт исходные 8-битные байты на решётке с шагом 5 по +//! каждому каналу (около 140 тысяч цветов, включая края; тест +//! `round_trip_is_byte_exact_on_lattice`). Это не полный перебор куба. use super::srgb::{srgb_from_hex, srgb_gamma, srgb_to_xyz}; @@ -113,12 +114,12 @@ pub fn p3_from_hex(hex: &str) -> Result<[f64; 3], String> { /// /// Точность: 6 знаков на компоненту — ошибка квантования печати ≤ 5·10⁻⁷ при /// полушаге 8-битного канала ≈ 2·10⁻³, запас > 3 порядков; байт-точность -/// round-trip доказана тестом на решётке всего куба. Политика альфы — единая +/// round-trip проверена тестом на решётке с шагом 5. Политика альфы — единая /// (`super::oklch::css_alpha_suffix`): та же, что у oklch-эмиссии. /// /// # Errors /// -/// `Err` — невалидный hex либо альфа вне [0, 1] сверх шумового эпсилона. +/// `Err` — невалидный hex, неконечная альфа либо альфа вне `[0, 1]`. pub fn p3_css_from_hex(hex: &str, alpha: Option) -> Result { let [r, g, b] = p3_from_hex(hex)?; let suffix = super::oklch::css_alpha_suffix(alpha)?; @@ -286,17 +287,26 @@ mod tests { } } - /// Политика альфы — единая с oklch-эмиссией (общий дом css_alpha_suffix): - /// NaN/грубый выход — ошибка, шум у краёв — кламп. + /// Политика alpha едина с oklch-эмиссией: любое недоменное значение — + /// ошибка. Даже малый clamp изменил бы публичное число и мог бы выдать + /// соседний конечный композит за сертифицированный. #[test] fn alpha_guard_shared_with_oklch() { assert!(p3_css_from_hex("#101012", Some(f64::NAN)).is_err()); assert!(p3_css_from_hex("#101012", Some(-10.0)).is_err()); assert!(p3_css_from_hex("#101012", Some(2.0)).is_err()); - let noisy = p3_css_from_hex("#101012", Some(-1e-7)).unwrap(); - assert!(noisy.ends_with(" / 0)"), "шум у нуля клампится: {noisy}"); - let over = p3_css_from_hex("#101012", Some(1.0 + 1e-9)).unwrap(); - assert!(over.ends_with(" / 1)"), "шум у единицы клампится: {over}"); + assert!(p3_css_from_hex("#101012", Some(-1e-7)).is_err()); + assert!(p3_css_from_hex("#101012", Some(1.0 + 1e-9)).is_err()); + assert!( + p3_css_from_hex("#101012", Some(0.0)) + .unwrap() + .ends_with(" / 0)") + ); + assert!( + p3_css_from_hex("#101012", Some(1.0)) + .unwrap() + .ends_with(" / 1)") + ); } /// Форма строки — контракт потребителя: `color(display-p3 R G B [/ A])`, diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 291683bc..2d8b1cb1 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -103,7 +103,7 @@ mod gamma_data; /// is therefore the *exact* decode for every reachable input — a table lookup /// that replaces the per-channel `powf` with zero loss (gated by /// `decode_table_matches_live_math` and `decode_reproduces_legacy_powf_path`). -fn decode_8bit(byte: u8) -> f64 { +pub(crate) fn decode_8bit(byte: u8) -> f64 { gamma_data::DECODE_8BIT[byte as usize] } diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 2f0603e6..21043236 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -1,6 +1,6 @@ //! Viewing conditions (surround parameters) for the CIECAM16 forward pass. //! -//! Holds the per-observer constants — adapting luminance, background factor, +//! Holds model viewing-condition inputs — adapting luminance, background factor, //! and the surround triplet `(F, c, N_c)` — that [`crate::spaces::cam16`] //! consumes. The surround presets are CIECAM16 Table 1 (average //! `[1.0, 0.69, 1.0]`, dim `[0.9, 0.59, 0.9]`), as tabulated in Li et al. @@ -98,21 +98,15 @@ impl ViewingConditions { /// but with reduced surround contrast per CIECAM16 Table 1: /// F = 0.9, c = 0.59, N_c = 0.9. /// - /// Produces lower J' for the same stimulus compared to average surround, - /// which matches human perception in darkened viewing environments. + /// Produces lower model J for the same stimulus than the average-surround preset. /// /// # Why dim (F = 0.9), not dark (F = 0.8), for a dark-theme UI? /// - /// A dark-theme UI (phone at night, monitor in night mode) is formally - /// closer to the *dark* surround, whose triplet (F = 0.8, c = 0.525, - /// N_c = 0.8) models a projector in *complete* darkness, where the stimulus - /// is the only light in the field. A screen in a dim room is different: it is - /// a self-luminous panel plus residual ambient light from the room, so its - /// effective surround never reaches that projector limit. The *dim* working - /// point (F = 0.9) sits between average and dark and is the better match for - /// that reality. This is a deliberate engine choice, not a strict CIE - /// mandate; the test-only `dark_surround` constructor keeps the F = 0.8 - /// endpoint available so it can still be exercised in comparisons and tests. + /// A CSS/product theme label does not determine the user's measured surround. + /// Lab Colors therefore freezes *dim* (F = 0.9) as an engine compatibility + /// preset, not as a universal observer claim or CIE mandate. The test-only + /// `dark_surround` constructor keeps the F = 0.8 endpoint available for + /// comparisons and tests. /// /// The documented dim triplet is asserted here so a silent regression to the /// dark parameters — the choice this rationale rejects — fails the doctest: @@ -149,8 +143,8 @@ impl ViewingConditions { /// чёрный фон (#000000, Y=0.0%) clampится к 0.5 % без значимого влияния на /// итоговый J: разница J(Yb=0.5) − J(Yb≈0) пренебрежимо мала на практике. /// - /// Используется в surround-aware оценке дефектов (`cleanliness`), где фон цвета - /// известен из дизайн-контекста — источник: CIECAM16 (Li et al. 2017). + /// Используется legacy `cleanliness` compatibility proxy как partial input + /// `Yb + theme`. Это не полный visual context и не observer verdict. pub fn srgb_with_yb(y_b_pct: f64) -> Self { // average surround: F=1.0, c=0.69, N_c=1.0 — CIECAM02/16 Table 1 Self::build(64.0, y_b_pct.clamp(0.5, 100.0), 1.0, 0.69, 1.0) @@ -161,7 +155,8 @@ impl ViewingConditions { /// Аналогичен [`dim_surround`](Self::dim_surround), но параметр `Yb` задаётся явно. /// Диапазон ограничен [0.5, 100.0] — нижняя граница по той же причине, что в /// [`srgb_with_yb`](Self::srgb_with_yb): Yb=0 вырождает CIECAM16 (nbb→∞). - /// Используется в surround-aware оценке дефектов. + /// Используется legacy compatibility proxy как partial `Yb + theme` input; + /// это не полная surround/observer model. pub fn dim_surround_with_yb(y_b_pct: f64) -> Self { // dim surround: F=0.9, c=0.59, N_c=0.9 — CIECAM02/16 Table 1 Self::build(64.0, y_b_pct.clamp(0.5, 100.0), 0.9, 0.59, 0.9) diff --git a/crates/labcolors-core/src/wcag.rs b/crates/labcolors-core/src/wcag.rs index 44bff876..2337472b 100644 --- a/crates/labcolors-core/src/wcag.rs +++ b/crates/labcolors-core/src/wcag.rs @@ -1,20 +1,11 @@ -//! WCAG 2.1 contrast ratio — the legal conformance floor. +//! Legacy WCAG 2.1 (2018) relative-luminance profile. //! -//! The European Accessibility Act (in force 2025-06-28) requires, through -//! EN 301 549, WCAG 2.1 level AA: a contrast ratio of at least 4.5:1 for normal -//! text (success criterion 1.4.3) and 3:1 for user-interface components and -//! graphical objects (1.4.11). This is the *standard relative-luminance* -//! contrast ratio — symmetric and chroma-blind — computed on the output sRGB -//! colour. It is the legal minimum beneath the perceptual LPC target the solver -//! aims for; it never replaces it (see [`crate::solve`]). -//! -//! The implementation below is a faithful, self-contained transcription of the -//! W3C definitions, deliberately independent of the CAM16/LPC pipeline so it can -//! be audited line-by-line against the spec: -//! and -//! . EN 301 549 (the harmonised -//! standard invoking WCAG 2.1 AA): -//! . +//! This module preserves the product's existing continuous `0.03928` profile +//! while the canonical WCAG 2.2 finite-domain profile is implemented under +//! issue #284. The current W3C Recommendation uses `0.04045`; therefore this +//! module must not be described as current normative WCAG, cross-runtime exact, +//! or a legal-applicability decision. It remains independent of CAM16/LPC so the +//! technical formula can be audited and replaced as one unit. /// WCAG 2.1 AA minimum contrast ratio for normal text (success criterion 1.4.3). pub(crate) const AA_TEXT_RATIO: f64 = 4.5; @@ -23,23 +14,91 @@ pub(crate) const AA_TEXT_RATIO: f64 = 4.5; /// (success criterion 1.4.11). pub(crate) const AA_UI_RATIO: f64 = 3.0; -/// Linearise one gamma-encoded sRGB channel in `[0, 1]`, per WCAG 2.1 §1.4.3. +/// Split used by the original WCAG 2.1 (2018) continuous formula. /// -/// The 0.03928 threshold is the value normatively fixed by WCAG; for 8-bit -/// inputs it selects the same piecewise branch as the IEC sRGB transfer -/// function, so quantised colours linearise identically either way. +/// The current WCAG Recommendation uses `0.04045`. Issue #284 owns that +/// versioned migration; changing this constant in place would silently mutate a +/// characterized numerical profile. +const LEGACY_CHANNEL_SPLIT: f64 = 0.039_28; + +/// First binary64 value on the power branch of the legacy split. +const LEGACY_CHANNEL_SPLIT_RIGHT: f64 = f64::from_bits(LEGACY_CHANNEL_SPLIT.to_bits() + 1); + +const RED_WEIGHT: f64 = 0.2126; +const GREEN_WEIGHT: f64 = 0.7152; +const BLUE_WEIGHT: f64 = 0.0722; + +/// Absolute headroom for the final three weighted binary64 operations in a +/// luminance interval. The material path is still platform-characterized +/// because `powf` has no repository-owned outward error bound. +const LUMINANCE_RANGE_MARGIN: f64 = 8.0 * f64::EPSILON; + +/// Linearise one gamma-encoded sRGB channel in `[0, 1]` under the frozen legacy +/// WCAG 2.1 (2018) profile. fn linearise(channel: f64) -> f64 { - if channel <= 0.039_28 { + if channel <= LEGACY_CHANNEL_SPLIT { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) } } -/// WCAG 2.1 relative luminance of a gamma-encoded sRGB colour `[r, g, b]` in -/// `[0, 1]`: `0.2126·R + 0.7152·G + 0.0722·B` over the linearised channels. +/// Characterized range of the legacy channel transfer over an ordered encoded +/// interval. +/// +/// The legacy constants create a small downward discontinuity immediately to +/// the right of `0.03928`. Endpoint-only evaluation is therefore invalid. When +/// the interval crosses the split, both the linear value at the split and the +/// power-branch value at the first representable input above it participate in +/// the extrema. `powf` remains platform-characterized rather than soundly +/// outward-rounded. +fn linearised_channel_range(encoded_lo: f64, encoded_hi: f64) -> (f64, f64) { + debug_assert!( + encoded_lo.is_finite() + && encoded_hi.is_finite() + && (0.0..=1.0).contains(&encoded_lo) + && (0.0..=1.0).contains(&encoded_hi) + && encoded_lo <= encoded_hi + ); + + let mut lo = linearise(encoded_lo).min(linearise(encoded_hi)); + let mut hi = linearise(encoded_lo).max(linearise(encoded_hi)); + if encoded_lo <= LEGACY_CHANNEL_SPLIT && encoded_hi > LEGACY_CHANNEL_SPLIT { + let at_split = linearise(LEGACY_CHANNEL_SPLIT); + let right_of_split = linearise(LEGACY_CHANNEL_SPLIT_RIGHT); + lo = lo.min(at_split).min(right_of_split); + hi = hi.max(at_split).max(right_of_split); + } + (lo, hi) +} + +/// Separable characterized luminance enclosure for three ordered encoded-sRGB +/// channel intervals. +/// +/// All WCAG weights are positive, so channel minima and maxima combine without +/// enumerating the eight RGB corners. A small final absolute pad covers the +/// fixed binary64 multiply/add sequence; the branch-sensitive `powf` calls keep +/// this a platform characterization rather than a cross-runtime proof. +pub(crate) fn relative_luminance_range(encoded_lo: [f64; 3], encoded_hi: [f64; 3]) -> (f64, f64) { + let channels = core::array::from_fn::<_, 3, _>(|channel| { + linearised_channel_range(encoded_lo[channel], encoded_hi[channel]) + }); + let lower = + (RED_WEIGHT * channels[0].0 + GREEN_WEIGHT * channels[1].0) + BLUE_WEIGHT * channels[2].0; + let upper = + (RED_WEIGHT * channels[0].1 + GREEN_WEIGHT * channels[1].1) + BLUE_WEIGHT * channels[2].1; + ( + (lower - LUMINANCE_RANGE_MARGIN).max(0.0), + (upper + LUMINANCE_RANGE_MARGIN).min(1.0), + ) +} + +/// Frozen legacy WCAG 2.1 (2018) relative luminance of a gamma-encoded sRGB +/// colour `[r, g, b]` in `[0, 1]`. pub(crate) fn relative_luminance(srgb: [f64; 3]) -> f64 { - 0.2126 * linearise(srgb[0]) + 0.7152 * linearise(srgb[1]) + 0.0722 * linearise(srgb[2]) + RED_WEIGHT * linearise(srgb[0]) + + GREEN_WEIGHT * linearise(srgb[1]) + + BLUE_WEIGHT * linearise(srgb[2]) } /// The `[1, 21]` WCAG ratio of two relative luminances: @@ -50,7 +109,8 @@ pub(crate) fn ratio_from_luminances(la: f64, lb: f64) -> f64 { (lighter + 0.05) / (darker + 0.05) } -/// WCAG 2.1 contrast ratio between two gamma-encoded sRGB colours, in `[1, 21]`. +/// Frozen legacy WCAG 2.1 (2018) contrast ratio between two gamma-encoded sRGB +/// colours, in `[1, 21]`. /// /// `(L_lighter + 0.05) / (L_darker + 0.05)`. Symmetric and polarity-agnostic by /// construction — unlike the signed perceptual LPC metric, which is why the two @@ -131,4 +191,48 @@ mod tests { assert_eq!(contrast_ratio(a, b).to_bits(), inlined.to_bits()); } } + + #[test] + fn legacy_channel_range_contains_both_sides_of_the_eotf_seam() { + assert_eq!( + LEGACY_CHANNEL_SPLIT_RIGHT.to_bits(), + LEGACY_CHANNEL_SPLIT.to_bits() + 1 + ); + let linear_side = LEGACY_CHANNEL_SPLIT / 12.92; + let power_side = ((LEGACY_CHANNEL_SPLIT_RIGHT + 0.055) / 1.055).powf(2.4); + assert!( + power_side < linear_side, + "fixture must expose the legacy seam" + ); + + // A wide interval makes the power-side seam value an interior extremum; + // endpoint-only evaluation cannot satisfy this assertion. + let (lo, hi) = linearised_channel_range(LEGACY_CHANNEL_SPLIT, 0.5); + assert!(lo <= power_side, "lower range omitted power side"); + assert!(hi >= linear_side, "upper range omitted linear side"); + } + + #[test] + fn luminance_range_is_separable_and_bounded() { + let (lo, hi) = relative_luminance_range( + [LEGACY_CHANNEL_SPLIT, 0.25, 0.75], + [LEGACY_CHANNEL_SPLIT_RIGHT, 0.5, 1.0], + ); + assert!(lo.is_finite() && hi.is_finite()); + assert!((0.0..=1.0).contains(&lo)); + assert!((0.0..=1.0).contains(&hi)); + assert!(lo <= hi); + + for rgb in [ + [LEGACY_CHANNEL_SPLIT, 0.25, 0.75], + [LEGACY_CHANNEL_SPLIT_RIGHT, 0.5, 1.0], + [LEGACY_CHANNEL_SPLIT_RIGHT, 0.25, 0.75], + ] { + let actual = relative_luminance(rgb); + assert!( + lo <= actual && actual <= hi, + "{actual} outside [{lo}, {hi}]" + ); + } + } } diff --git a/crates/labcolors-core/tests/data/labui_emission_golden.txt b/crates/labcolors-core/tests/data/labui_emission_golden.txt index 1f614862..fba22ce5 100644 --- a/crates/labcolors-core/tests/data/labui_emission_golden.txt +++ b/crates/labcolors-core/tests/data/labui_emission_golden.txt @@ -77,10 +77,10 @@ srgb|#FFFFFF|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#FFFFFF|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#FFFFFF|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#FFFFFF|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#FFFFFF|fx-glow-brand=glow(#8DBCFF,#007AFF,1.0000) -srgb|#FFFFFF|fx-glow-danger=glow(#FFAA9D,#FF3B30,1.0000) -srgb|#FFFFFF|fx-glow-warning=glow(#FFD19F,#FFA100,1.0000) -srgb|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,1.0000) +srgb|#FFFFFF|fx-glow-brand=glow(#8DBCFF,#007AFF,0.5000) +srgb|#FFFFFF|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.5000) +srgb|#FFFFFF|fx-glow-warning=glow(#FFD19F,#FFA100,0.5000) +srgb|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.5000) srgb|#FFFFFF|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#FFFFFF|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#FFFFFF|fill-neutral=rgba(#787880,1) @@ -184,10 +184,10 @@ srgb|#F2F2F7|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#F2F2F7|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#F2F2F7|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#F2F2F7|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#F2F2F7|fx-glow-brand=glow(#8DBCFF,#007AFF,1.0000) -srgb|#F2F2F7|fx-glow-danger=glow(#FFAA9D,#FF3B30,1.0000) -srgb|#F2F2F7|fx-glow-warning=glow(#FFD19F,#FFA100,0.9138) -srgb|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.6538) +srgb|#F2F2F7|fx-glow-brand=glow(#8DBCFF,#007AFF,0.9687) +srgb|#F2F2F7|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.9980) +srgb|#F2F2F7|fx-glow-warning=glow(#FFD19F,#FFA100,0.9376) +srgb|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.6707) srgb|#F2F2F7|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#F2F2F7|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#F2F2F7|fill-neutral=rgba(#787880,1) @@ -291,10 +291,10 @@ srgb|#7F7F7F|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#7F7F7F|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#7F7F7F|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#7F7F7F|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#7F7F7F|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1061) -srgb|#7F7F7F|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1055) -srgb|#7F7F7F|fx-glow-warning=glow(#FFD19F,#FFA100,0.0681) -srgb|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0430) +srgb|#7F7F7F|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1097) +srgb|#7F7F7F|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1094) +srgb|#7F7F7F|fx-glow-warning=glow(#FFD19F,#FFA100,0.0711) +srgb|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0469) srgb|#7F7F7F|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#7F7F7F|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#7F7F7F|fill-neutral=rgba(#787880,1) @@ -399,9 +399,9 @@ srgb|#1C1C1E|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#1C1C1E|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#1C1C1E|fx-focus-ring-neutral=rgba(#101012,1) srgb|#1C1C1E|fx-glow-brand=glow(#8DBCFF,#007AFF,0.0599) -srgb|#1C1C1E|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0551) -srgb|#1C1C1E|fx-glow-warning=glow(#FFD19F,#FFA100,0.0374) -srgb|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0242) +srgb|#1C1C1E|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0570) +srgb|#1C1C1E|fx-glow-warning=glow(#FFD19F,#FFA100,0.0379) +srgb|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0243) srgb|#1C1C1E|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#1C1C1E|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#1C1C1E|fill-neutral=rgba(#787880,1) @@ -505,10 +505,10 @@ srgb|#101012|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#101012|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#101012|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#101012|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#101012|fx-glow-brand=glow(#8DBCFF,#007AFF,0.0568) -srgb|#101012|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0560) -srgb|#101012|fx-glow-warning=glow(#FFD19F,#FFA100,0.0364) -srgb|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0230) +srgb|#101012|fx-glow-brand=glow(#8DBCFF,#007AFF,0.0569) +srgb|#101012|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0563) +srgb|#101012|fx-glow-warning=glow(#FFD19F,#FFA100,0.0381) +srgb|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0231) srgb|#101012|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#101012|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#101012|fill-neutral=rgba(#787880,1) @@ -612,10 +612,10 @@ srgb|#3478F6|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#3478F6|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#3478F6|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#3478F6|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#3478F6|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1316) -srgb|#3478F6|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1404) +srgb|#3478F6|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1393) +srgb|#3478F6|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1422) srgb|#3478F6|fx-glow-warning=glow(#FFD19F,#FFA100,0.0763) -srgb|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0556) +srgb|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0561) srgb|#3478F6|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#3478F6|fx-skeleton-highlight=rgba(#787880,0.039) srgb|#3478F6|fill-neutral=rgba(#787880,1) @@ -719,10 +719,10 @@ dim|#FFFFFF|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#FFFFFF|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#FFFFFF|fx-focus-ring-warning=rgba(#FF9008,1) dim|#FFFFFF|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#FFFFFF|fx-glow-brand=glow(#A0C5FF,#4A8FFF,1.0000) -dim|#FFFFFF|fx-glow-danger=glow(#FFA89F,#FF3A3A,1.0000) -dim|#FFFFFF|fx-glow-warning=glow(#FFC89D,#FF9008,1.0000) -dim|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,1.0000) +dim|#FFFFFF|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.5000) +dim|#FFFFFF|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.5000) +dim|#FFFFFF|fx-glow-warning=glow(#FFC89D,#FF9008,0.5000) +dim|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.5000) dim|#FFFFFF|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#FFFFFF|fx-skeleton-highlight=rgba(#787880,0.039) dim|#FFFFFF|fill-neutral=rgba(#787880,1) @@ -826,10 +826,10 @@ dim|#F2F2F7|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#F2F2F7|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#F2F2F7|fx-focus-ring-warning=rgba(#FF9008,1) dim|#F2F2F7|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#F2F2F7|fx-glow-brand=glow(#A0C5FF,#4A8FFF,1.0000) -dim|#F2F2F7|fx-glow-danger=glow(#FFA89F,#FF3A3A,1.0000) -dim|#F2F2F7|fx-glow-warning=glow(#FFC89D,#FF9008,1.0000) -dim|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.7308) +dim|#F2F2F7|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.9687) +dim|#F2F2F7|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.9808) +dim|#F2F2F7|fx-glow-warning=glow(#FFC89D,#FF9008,0.9808) +dim|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.7692) dim|#F2F2F7|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#F2F2F7|fx-skeleton-highlight=rgba(#787880,0.039) dim|#F2F2F7|fill-neutral=rgba(#787880,1) @@ -933,10 +933,10 @@ dim|#7F7F7F|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#7F7F7F|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#7F7F7F|fx-focus-ring-warning=rgba(#FF9008,1) dim|#7F7F7F|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#7F7F7F|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0906) -dim|#7F7F7F|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1202) -dim|#7F7F7F|fx-glow-warning=glow(#FFC89D,#FF9008,0.0761) -dim|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0508) +dim|#7F7F7F|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0924) +dim|#7F7F7F|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1207) +dim|#7F7F7F|fx-glow-warning=glow(#FFC89D,#FF9008,0.0791) +dim|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0547) dim|#7F7F7F|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#7F7F7F|fx-skeleton-highlight=rgba(#787880,0.039) dim|#7F7F7F|fill-neutral=rgba(#787880,1) @@ -1040,10 +1040,10 @@ dim|#1C1C1E|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#1C1C1E|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#1C1C1E|fx-focus-ring-warning=rgba(#FF9008,1) dim|#1C1C1E|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#1C1C1E|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0380) -dim|#1C1C1E|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0484) -dim|#1C1C1E|fx-glow-warning=glow(#FFC89D,#FF9008,0.0351) -dim|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0198) +dim|#1C1C1E|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0401) +dim|#1C1C1E|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0486) +dim|#1C1C1E|fx-glow-warning=glow(#FFC89D,#FF9008,0.0363) +dim|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0199) dim|#1C1C1E|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#1C1C1E|fx-skeleton-highlight=rgba(#787880,0.039) dim|#1C1C1E|fill-neutral=rgba(#787880,1) @@ -1147,10 +1147,10 @@ dim|#101012|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#101012|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#101012|fx-focus-ring-warning=rgba(#FF9008,1) dim|#101012|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#101012|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0360) -dim|#101012|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0460) -dim|#101012|fx-glow-warning=glow(#FFC89D,#FF9008,0.0333) -dim|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0188) +dim|#101012|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0381) +dim|#101012|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0462) +dim|#101012|fx-glow-warning=glow(#FFC89D,#FF9008,0.0345) +dim|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0189) dim|#101012|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#101012|fx-skeleton-highlight=rgba(#787880,0.039) dim|#101012|fill-neutral=rgba(#787880,1) @@ -1254,10 +1254,10 @@ dim|#3478F6|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#3478F6|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#3478F6|fx-focus-ring-warning=rgba(#FF9008,1) dim|#3478F6|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#3478F6|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.1123) -dim|#3478F6|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1466) -dim|#3478F6|fx-glow-warning=glow(#FFC89D,#FF9008,0.0911) -dim|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0556) +dim|#3478F6|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.1189) +dim|#3478F6|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1484) +dim|#3478F6|fx-glow-warning=glow(#FFC89D,#FF9008,0.0936) +dim|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0561) dim|#3478F6|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#3478F6|fx-skeleton-highlight=rgba(#787880,0.039) dim|#3478F6|fill-neutral=rgba(#787880,1) diff --git a/crates/labcolors-core/tests/empirical_inventory.rs b/crates/labcolors-core/tests/empirical_inventory.rs index a2b6819a..fc96aabe 100644 --- a/crates/labcolors-core/tests/empirical_inventory.rs +++ b/crates/labcolors-core/tests/empirical_inventory.rs @@ -503,22 +503,21 @@ fn scan_source(module: &str, source: &str, allowlist: &[&str]) -> Vec 0 - && let Some((field, value)) = parse_default_field_literal(line) - { - let name = format!("{module_stem}_DEFAULT_{}", field.to_ascii_uppercase()); - if !allowlist.contains(&name.as_str()) { - let (has_marker, needs_science, marker_line) = marker_above(&lines, idx); - out.push(DetectedConst { - module: module.to_string(), - line: prod[idx].0, - name, - value, - has_marker, - needs_science, - marker_line, - }); + if in_default_body && default_brace_depth > 0 { + if let Some((field, value)) = parse_default_field_literal(line) { + let name = format!("{module_stem}_DEFAULT_{}", field.to_ascii_uppercase()); + if !allowlist.contains(&name.as_str()) { + let (has_marker, needs_science, marker_line) = marker_above(&lines, idx); + out.push(DetectedConst { + module: module.to_string(), + line: prod[idx].0, + name, + value, + has_marker, + needs_science, + marker_line, + }); + } } } @@ -1052,10 +1051,10 @@ fn read_workspace_doc(rel: &str) -> Option { } // ───────────────────────────────────────────────────────────────────────────── -// Muddiness-Law (M-row) auditor — resurrects the retired external `mud-oracle`. +// Legacy-proxy M-row auditor — resurrects the retired external `mud-oracle`. // // BUG CLASS this closes: the GATE-1/2/3 integer-row parser SKIPS the `M-` rows -// (their first cell `M-01` is not a bare integer), so the Muddiness-Law constants +// (their first cell `M-01` is not a bare integer), so the legacy-proxy constants // of `cleanliness.rs` had NO in-repo gate — the SSOT once deferred them to // `.agents/tools/mud-oracle/verify_inventory.js`, which does not exist on this // tree. This auditor pulls that check IN-TREE: every non-REMOVED `M-` row must @@ -1068,7 +1067,7 @@ fn read_workspace_doc(rel: &str) -> Option { // `cleanliness.rs` (no duplication). Shared verbatim by the gate and the RED-proof. // ───────────────────────────────────────────────────────────────────────────── -/// One parsed Muddiness-Law row from the SSOT (`M-` keyed). +/// One parsed legacy-proxy row from the SSOT (`M-` keyed). #[derive(Debug, Clone)] struct MudRow { /// Const name, markdown decoration stripped (backticks, `~~…~~`). @@ -1088,7 +1087,7 @@ fn decimal_places(s: &str) -> usize { } } -/// Parse the `M-` rows of the SSOT. A mud row is a `|`-row whose first cell starts +/// Parse the `M-` rows of the SSOT. A legacy-proxy row is a `|`-row whose first cell starts /// with `M-`. Columns: `mud-id | name | value | module | status | rationale`. fn parse_mud_rows(text: &str) -> Vec { let mut rows = Vec::new(); @@ -1130,10 +1129,10 @@ fn cleanliness_const_values(src: &str) -> std::collections::BTreeMap() - { - map.insert(name, v); + if let Some(ConstDecl::Numeric { name, value, .. }) = parse_const_decl(line) { + if let Ok(v) = value.parse::() { + map.insert(name, v); + } } } map @@ -1252,7 +1251,7 @@ fn rewrite_mud_row_value(text: &str, name: &str, new_value: &str) -> String { fn mud_rows_match_cleanliness_source() { let ssot = std::fs::read_to_string(inventory_path()).unwrap_or_else(|e| { panic!( - "mud auditor cannot read SSOT at {} ({e})", + "legacy-proxy M-row auditor cannot read SSOT at {} ({e})", inventory_path().display() ) }); @@ -1260,13 +1259,13 @@ fn mud_rows_match_cleanliness_source() { let active = rows.iter().filter(|r| !r.removed).count(); assert!( active > 0, - "mud auditor parsed zero ACTIVE M-rows — the parser is mis-scoped (a vacuous gate)." + "legacy-proxy M-row auditor parsed zero ACTIVE M-rows — the parser is mis-scoped (a vacuous gate)." ); let src = read_module("cleanliness.rs"); let defects = mud_row_defects(&rows, &src); assert!( defects.is_empty(), - "Muddiness-Law auditor FAILED — {} M-row(s) do not join to cleanliness.rs at the \ + "legacy-proxy M-row auditor FAILED — {} M-row(s) do not join to cleanliness.rs at the \ documented value/precision:\n {}", defects.len(), defects.join("\n ") @@ -1836,7 +1835,7 @@ fn red_proof_audit_probe() { real_citation_defects.join("\n ") ); - // 8. Muddiness-Law auditor must flip RED on an M-row value drift. Mutate ONE + // 8. Legacy-proxy M-row auditor must flip RED on an M-row value drift. Mutate ONE // ACTIVE M-row's value in an IN-MEMORY copy of the SSOT and run the *same* // `mud_row_defects` the live gate runs (INV-4), so a mutation to the auditor // is caught here, not green-from-birth. @@ -1854,12 +1853,12 @@ fn red_proof_audit_probe() { let mud_drifted = rewrite_mud_row_value(&real_ssot, mud_probe, "196.9172"); assert_ne!( mud_drifted, real_ssot, - "RED-proof setup wrong — mud value-drift splice did not change the SSOT (row `{mud_probe}` not found)." + "RED-proof setup wrong — legacy-proxy value-drift splice did not change the SSOT (row `{mud_probe}` not found)." ); let mud_drift = mud_row_defects(&parse_mud_rows(&mud_drifted), &cleanliness_src); assert!( mud_drift.iter().any(|d| d.contains(mud_probe)), - "RED-proof FAILED (mud value-drift path) — drifting M-row `{mud_probe}` value to `196.9172` \ - did NOT flip the auditor RED; the Muddiness-Law audit is green-from-birth. Saw: {mud_drift:?}" + "RED-proof FAILED (legacy-proxy value-drift path) — drifting M-row `{mud_probe}` value to `196.9172` \ + did NOT flip the auditor RED; the legacy-proxy M-row audit is green-from-birth. Saw: {mud_drift:?}" ); } diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index 4381285b..7d102a7c 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -19,14 +19,15 @@ //! или на границе вызова) роняет property с МИНИМИЗИРОВАННЫМ (shrunk) контрпримером. //! Зелёный-с-рождения запрещён (был бы театром). Механика инъекции описана в PR. //! -//! # ЗАМЕЧАНИЕ ПО monotonicity (задокументированный vs фактический инвариант) +//! # ЗАМЕЧАНИЕ ПО monotonicity //! -//! `muddiness_oklch` документирован как «строго монотонный в C при фиксированных L,h». -//! Это ВЕРНО только для ТЁПЛЫХ оттенков (`sin(h) ≥ 0`, h∈[0°,180°]): там оба зависящих +//! Historical API identifier `muddiness_oklch` denotes an experimental compatibility +//! proxy, not a human judgement. Its coordinate is nondecreasing in C only for warm +//! hues (`sin(h) ≥ 0`, h∈[0°,180°]): там оба зависящих //! от C множителя — `neutral_gate(C)` и b-гейт `sigmoid((C·sin h − B0)/BW)` — растут в C. //! Для ХОЛОДНЫХ оттенков (`sin(h) < 0`, h∈(180°,360°)) b-гейт УБЫВАЕТ в C (b = C·sin h < 0 -//! уходит от B0), поэтому произведение НЕ монотонно — глобальное утверждение доки -//! переусиливает. Property ниже проверяет ИСТИННЫЙ инвариант (тёплая полуплоскость); +//! уходит от B0), поэтому произведение НЕ монотонно. Property ниже проверяет +//! математический инвариант тёплой полуплоскости; //! холодный контрпример зафиксирован тестом `muddiness_cool_hue_is_not_monotone_finding` //! как characterization реального поведения (не одобрение — фиксация факта для владельца). @@ -138,8 +139,8 @@ fn anchors_ld(light: String, dark: String) -> ThemeAnchors { // ───────────────────────────────────────────────────────────────────────────── // СВОЙСТВО 1 — muddiness_oklch монотонна в C (тёплая полуплоскость) // -// КЛАСС: инвариант монотонности параметр-свободной цены грязи. Закрывает весь -// класс «муддинес немонотонен по хроме для тёплого оттенка», не один вход. +// КЛАСС: инвариант монотонности frozen compatibility coordinate. Закрывает +// весь класс регрессий C-зависимости для тёплого оттенка, не один вход. // БЬЁТ НА МУТАЦИИ: замена `neutral_gate` sigmoid на константу / переворот знака // аргумента / отбрасывание C-зависимости → монотонность рушится → RED. // ───────────────────────────────────────────────────────────────────────────── @@ -192,13 +193,13 @@ fn muddiness_strictly_increases_in_chroma_in_the_steep_warm_band() { } /// Закрытие ДЫРЫ, найденной mutation-прогоном (survivor `cleanliness.rs:541:35 -/// replace + with * in muddiness_in_context`): surround-aware путь обязан считать -/// РЕАЛЬНУЮ Oklab-хрому `√(a²+b²)`. Мутант `a²·b²` схлопывает хрому тёплых муддистых -/// цветов почти в ноль (ниже C0) → mud падает с ~0.65 до ~0.03. Тёплые муддистые -/// оливы обязаны читаться грязными (mud > 0.3) в контексте — это убивает мутанта. +/// replace + with * in muddiness_in_context`): local-context path must calculate +/// Euclidean Oklab chroma `sqrt(a²+b²)`. The `a²*b²` mutant collapses these +/// fixtures below C0 and moves the frozen proxy from roughly 0.5-0.7 to ~0.03. +/// The `> 0.3` boundary is mutation characterization, not a human verdict. /// Заземлено замером: #6B6B2E=0.651, #8A7A2E=0.622, #7A5A20=0.666, #B8860B=0.506. #[test] -fn muddiness_in_context_reflects_real_oklab_chroma() { +fn muddiness_in_context_uses_euclidean_oklab_chroma() { let ctx = DefectContext { bg_hex: "#FFFFFF", theme: Theme::Light, @@ -207,16 +208,15 @@ fn muddiness_in_context_reflects_real_oklab_chroma() { let mud = muddiness_in_context(hex, ctx).unwrap(); assert!( mud > 0.3, - "тёплый мутный {hex} обязан читаться грязным в контексте (√(a²+b²)); \ - схлопнутая хрома дала бы ~0. Получено mud={mud}" + "fixture {hex} proxy must remain > 0.3 with Euclidean sqrt(a²+b²); \ + collapsed chroma would approach zero. Got {mud}" ); } } -/// Characterization реального поведения: для ХОЛОДНОГО оттенка (h=270°, sin=−1) -/// муддинес НЕ монотонен по C — сперва растёт, потом падает. Это фиксирует -/// расхождение между глобальным утверждением доки и кодом (находка для владельца), -/// НЕ одобряя его. Числа заземлены прямым вычислением ядра. +/// Characterization: for a cool hue (h=270°, sin=-1), the compatibility +/// coordinate is not monotone in C: it first rises, then falls. This records +/// current mathematics without assigning perceptual meaning. #[test] fn muddiness_cool_hue_is_not_monotone_finding() { let l = 0.3; diff --git a/crates/labcolors-ffi/Cargo.toml b/crates/labcolors-ffi/Cargo.toml index 1901a879..03dff7d9 100644 --- a/crates/labcolors-ffi/Cargo.toml +++ b/crates/labcolors-ffi/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "labcolors-ffi" +publish = false version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Нативный (UniFFI) биндинг поверх ядра labcolors-core — доказательство ДИНАМИЧНОГО рантайм-ядра на Apple-платформах. Экспортирует рантайм-контраст-ядро (контраст/резолв/лестницы/подложка→α/мутность) в Swift." +rust-version.workspace = true +description = "Native UniFFI boundary over labcolors-core with a generated Swift surface and explicit-profile Glow point decisions." [lib] # Имя lib-артефакта (без префикса пакета): даёт `liblabcolors.{a,dylib}` и @@ -28,7 +30,7 @@ path = "src/bin/uniffi_bindgen.rs" labcolors-core = { path = "../labcolors-core" } # Нативная FFI-обвязка. Библиотечная зависимость БЕЗ фичи cli (иначе clap течёт # в артефакт); cli включается только через passthrough-фичу ниже на генерации. -uniffi = { version = "0.32" } +uniffi = { version = "0.32", default-features = false } # Derive-only proc-macro: даёт `ColorError` реализацию `std::error::Error` # (требование `uniffi::Error`) без рантайм-кода. thiserror УЖЕ в дереве (wasm). thiserror = "2" diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index eea9cfb7..77febaf7 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -1,5 +1,7 @@ -//! Нативный биндинг ядра `labcolors-core` через UniFFI — **доказательство -//! динамического рантайм-ядра** на Apple-платформах. +//! Нативная ABI-граница ядра `labcolors-core` через UniFFI. Сгенерированная +//! Swift-поверхность доказывает динамический вызов ядра в текущем активном +//! Linux x86_64 conformance-гейте; Apple ABI, macOS/arm64 и iOS этим прогоном +//! не аттестованы. //! //! # Архитектурный закон //! @@ -20,9 +22,18 @@ //! | [`solve_contrast`] | `solve` | `solve` | //! | [`ladder_alpha`] | `ladders` | `LadderPosition::alpha_pair` | //! | [`composite`] / [`min_alpha`] | `alpha` | `alpha::composite_hex` / `alpha::min_alpha_hex` | -//! | [`muddiness`] | `muddiness` | `cleanliness::muddiness_from_hex` | +//! | [`muddiness`] | `muddiness` legacy compatibility vectors | `cleanliness::muddiness_from_hex` | //! | [`core_version`] | `manifest` | версия ядра | //! +//! [`solve_glow_point`] — отдельный low-level contract test нативной границы: +//! stable-профиль переносит `Indeterminate` с site + typed evidence без fallback +//! для non-trivial selection, а exact byte-no-op возвращает determinate без CAM16; +//! legacy выбирается только явно. Его CAM16-диагностика не объявлена +//! bit-exact между платформами; `bit-exact` относится лишь к certificate +//! encoded-sRGB8 screen-композитора. Output — algebraic sum +//! `StableExactNoop | LegacyReached | LegacyUnreachable | Indeterminate`, а не +//! независимые provenance-поля с незаконными cross-product. +//! //! Резолв полной темы из JSON-конфига (агностичный движок) СОЗНАТЕЛЬНО вне //! среза: он требует serde-границы конфига (живёт в WASM-крейте) — перенос её //! в нативный крейт либо дублировал бы её (риск дрейфа), либо тянул бы @@ -35,8 +46,11 @@ use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ - BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, Theme as CoreTheme, - ViewingConditions, recheck_against, solve, + BgInput, ChromaPolicy, Contract, DecisionGuaranteeV1, Gamut, GlowCompositeGuaranteeV1, + GlowCompositeProfileV1, GlowDecisionProfileV1, GlowDiagnosticProfileV1, + GlowTargetStatus as CoreGlowTargetStatus, Hue, LadderPosition, NumericalDecisionV1, + NumericalIndeterminacyV1, NumericalSiteIdV1, Theme as CoreTheme, ViewingConditions, + recheck_against, solve, solve_screen_alpha_for_dj, srgb_encoded_from_hex, }; // Регистрирует UniFFI-scaffolding под namespace = имя крейта (`labcolors`). @@ -131,6 +145,108 @@ pub struct Solved { pub floor_override: bool, } +/// Explicit profile numerical Glow decision; default намеренно отсутствует. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum GlowDecisionProfile { + /// Stable path: без sound bound возвращает Indeterminate. + StableV1, + /// Явный прежний CAM16/libm-dependent runtime path. + LegacyPlatformDependentV1, +} + +impl GlowDecisionProfile { + fn to_core(self) -> GlowDecisionProfileV1 { + match self { + Self::StableV1 => GlowDecisionProfileV1::StableV1, + Self::LegacyPlatformDependentV1 => GlowDecisionProfileV1::LegacyPlatformDependentV1, + } + } +} + +/// Exact point-composite profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum GlowCompositeProfile { + /// Encoded sRGB8 screen v1. + EncodedSrgb8ScreenV1, +} + +/// Exact point-composite guarantee. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum GlowCompositeGuarantee { + /// Byte-exact certificate. + BitExact, +} + +/// Зарегистрированный branch-sensitive site. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum NumericalSiteId { + /// Glow target-or-maximum selection. + GlowTargetOrMaximumV1, +} + +/// Неразделимая причина Indeterminate вместе с её evidence. +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum NumericalIndeterminacy { + /// Для site нет sound error bound. + SoundBoundUnavailable, + /// Sound outward interval пересекает semantic boundary. + IntervalOverlap { + /// Нижняя доказанная граница. + lower: f64, + /// Верхняя доказанная граница. + upper: f64, + }, +} + +/// Общий exact point-composite payload determinate Glow outcome. +/// +/// Semantic provenance задаёт владеющий вариант [`GlowPointDecision`], а не +/// независимые поля, из которых можно собрать невозможную комбинацию. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct GlowPointValue { + /// Exact composite profile. + pub composite_profile: GlowCompositeProfile, + /// Exact composite guarantee. + pub composite_guarantee: GlowCompositeGuarantee, + /// Canonical layer alpha. + pub alpha: f64, + /// Shortest-roundtrip CSS alpha. + pub alpha_css: String, + /// Requested target. + pub target_dj: f64, + /// Diagnostic achieved value. + pub achieved_dj: f64, + /// Exact point composite. + pub composite_hex: String, +} + +/// Low-level point Glow decision как сумма только допустимых provenance-state. +#[derive(Debug, Clone, PartialEq, uniffi::Enum)] +pub enum GlowPointDecision { + /// Stable byte-exact no-op: target недостижим без appearance selection. + StableExactNoop { + /// Exact point-composite payload. + value: GlowPointValue, + }, + /// Explicit legacy CAM16/libm selection достиг target. + LegacyReached { + /// Exact point-composite payload выбранного состояния. + value: GlowPointValue, + }, + /// Explicit legacy CAM16/libm selection не достиг target и вернул максимум. + LegacyUnreachable { + /// Exact point-composite payload диагностически выбранного максимума. + value: GlowPointValue, + }, + /// Stable path не выбрал состояние без sound bound и не сделал fallback. + Indeterminate { + /// Registered site. + site_id: NumericalSiteId, + /// Typed reason and its sound interval, if present. + evidence: NumericalIndeterminacy, + }, +} + /// Ошибки границы — сматчиваемые на Swift-стороне (бросаются как исключения). #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum ColorError { @@ -159,23 +275,41 @@ pub enum ColorError { /// Стабильный машинный код причины. code: String, }, + /// Невалидный low-level Glow request на публичной границе. + #[error("невалидный glow request: {reason}")] + InvalidGlowRequest { + /// Человекочитаемая причина. + reason: String, + }, + /// FFI-adapter получил internal postcondition failure либо вариант core- + /// контракта, который эта версия нативной поверхности не умеет представить + /// без потери смысла. Это не невалидный пользовательский request. + #[error("несовместимый контракт ядра: {reason}")] + IncompatibleCoreContract { + /// Человекочитаемая причина internal/core-surface несовместимости. + reason: String, + }, } /// Стабильный код недостижимости — тождественен маппингу WASM-границы /// (`labcolors-wasm/src/engine.rs`) и conformance-пака. Общий контракт имён для -/// ВСЕХ биндингов: одна причина → один код на любой платформе. -fn unreachable_code(err: &labcolors_core::Unreachable) -> &'static str { +/// ВСЕХ биндингов: одна известная причина → один код на любой платформе; +/// неизвестный forward-вариант — несовместимость версии, а не fallback-code. +fn unreachable_code(err: &labcolors_core::Unreachable) -> Result<&'static str, ColorError> { use labcolors_core::Unreachable as U; match err { - U::BelowContrastFloor { .. } => "below_contrast_floor", - U::ExceedsRange { .. } => "exceeds_range", - U::QuantizationGap { .. } => "quantization_gap", - U::FloorUnreachable { .. } => "floor_unreachable", - U::PolarityMismatch { .. } => "polarity_mismatch", - U::GamutUnsupported => "gamut_unsupported", - U::InvalidInput(_) => "invalid_input", - // `Unreachable` помечен `#[non_exhaustive]`; forward-compat-арм. - _ => "unreachable", + U::BelowContrastFloor { .. } => Ok("below_contrast_floor"), + U::ExceedsRange { .. } => Ok("exceeds_range"), + U::QuantizationGap { .. } => Ok("quantization_gap"), + U::FloorUnreachable { .. } => Ok("floor_unreachable"), + U::PolarityMismatch { .. } => Ok("polarity_mismatch"), + U::GamutUnsupported => Ok("gamut_unsupported"), + U::InvalidInput(_) => Ok("invalid_input"), + U::InternalInvariant(reason) => Err(ColorError::IncompatibleCoreContract { + reason: format!("internal core invariant failure: {reason}"), + }), + // Forward core variant — несовместимость adapter, не выдуманный code. + _ => Err(incompatible_core_variant("Unreachable")), } } @@ -234,7 +368,9 @@ pub fn recheck(bg: String, fgs: Vec, theme: Theme) -> Result Err(ColorError::Unreachable { - code: unreachable_code(&e).to_string(), - }), + Err(e) => { + let code = unreachable_code(&e)?; + Err(ColorError::Unreachable { + code: code.to_string(), + }) + } } } @@ -312,7 +451,204 @@ pub fn min_alpha(tint: String, bg: String) -> Result { min_alpha_hex(&tint, &bg).map_err(|reason| ColorError::InvalidColor { reason }) } -/// Оценка мутности («грязи») цвета `[0,1]`: 0 — чистый, 1 — грязный. +fn incompatible_core_variant(name: &str) -> ColorError { + ColorError::IncompatibleCoreContract { + reason: format!("unknown core {name} variant"), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GlowDeterminateKind { + StableExactNoop, + LegacyReached, + LegacyUnreachable, +} + +fn glow_determinate_kind( + requested_profile: GlowDecisionProfile, + guarantee: DecisionGuaranteeV1, + status: CoreGlowTargetStatus, + selection_diagnostic_profile: Option, +) -> Result { + match ( + requested_profile, + guarantee, + status, + selection_diagnostic_profile, + ) { + ( + GlowDecisionProfile::StableV1, + DecisionGuaranteeV1::BitExact, + CoreGlowTargetStatus::ExactNoopUnreachable, + None, + ) => Ok(GlowDeterminateKind::StableExactNoop), + ( + GlowDecisionProfile::LegacyPlatformDependentV1, + DecisionGuaranteeV1::LegacyPlatformDependentV1, + CoreGlowTargetStatus::LegacyReached, + Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + ) => Ok(GlowDeterminateKind::LegacyReached), + ( + GlowDecisionProfile::LegacyPlatformDependentV1, + DecisionGuaranteeV1::LegacyPlatformDependentV1, + CoreGlowTargetStatus::LegacyUnreachable, + Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + ) => Ok(GlowDeterminateKind::LegacyUnreachable), + illegal => Err(ColorError::IncompatibleCoreContract { + reason: format!("illegal Glow determinate provenance from core: {illegal:?}"), + }), + } +} + +fn composite_profile_to_ffi( + profile: GlowCompositeProfileV1, +) -> Result { + match profile { + GlowCompositeProfileV1::EncodedSrgb8ScreenV1 => { + Ok(GlowCompositeProfile::EncodedSrgb8ScreenV1) + } + _ => Err(incompatible_core_variant("GlowCompositeProfileV1")), + } +} + +fn composite_guarantee_to_ffi( + guarantee: GlowCompositeGuaranteeV1, +) -> Result { + match guarantee { + GlowCompositeGuaranteeV1::BitExact => Ok(GlowCompositeGuarantee::BitExact), + _ => Err(incompatible_core_variant("GlowCompositeGuaranteeV1")), + } +} + +fn numerical_site_to_ffi(site: NumericalSiteIdV1) -> Result { + match site { + NumericalSiteIdV1::GlowTargetOrMaximumV1 => Ok(NumericalSiteId::GlowTargetOrMaximumV1), + _ => Err(incompatible_core_variant("NumericalSiteIdV1")), + } +} + +fn validate_glow_request(tint: &str, background: &str, target_dj: f64) -> Result<(), ColorError> { + srgb_encoded_from_hex(tint).map_err(|reason| ColorError::InvalidGlowRequest { + reason: format!("invalid tint: {reason}"), + })?; + srgb_encoded_from_hex(background).map_err(|reason| ColorError::InvalidGlowRequest { + reason: format!("invalid background: {reason}"), + })?; + if !target_dj.is_finite() || target_dj <= 0.0 { + return Err(ColorError::InvalidGlowRequest { + reason: format!("target_dj должен быть конечным и > 0, получено {target_dj}"), + }); + } + Ok(()) +} + +fn map_prevalidated_glow_core_result(result: Result) -> Result { + result.map_err(|reason| ColorError::IncompatibleCoreContract { + reason: format!("core rejected a prevalidated Glow request: {reason}"), + }) +} + +fn glow_point_value_to_ffi( + value: &labcolors_core::GlowSolve, +) -> Result { + let certificate = value.composite_certificate(); + Ok(GlowPointValue { + composite_profile: composite_profile_to_ffi(certificate.profile())?, + composite_guarantee: composite_guarantee_to_ffi(certificate.guarantee())?, + alpha: value.alpha(), + alpha_css: value.alpha_css().to_string(), + target_dj: value.target_dj(), + achieved_dj: value.achieved_dj(), + composite_hex: value.composite_hex().to_string(), + }) +} + +fn indeterminacy_to_ffi( + evidence: NumericalIndeterminacyV1, +) -> Result { + match evidence { + NumericalIndeterminacyV1::SoundBoundUnavailable => { + Ok(NumericalIndeterminacy::SoundBoundUnavailable) + } + NumericalIndeterminacyV1::IntervalOverlap(interval) => { + Ok(NumericalIndeterminacy::IntervalOverlap { + lower: interval.lower(), + upper: interval.upper(), + }) + } + _ => Err(incompatible_core_variant("NumericalIndeterminacyV1")), + } +} + +/// Point Glow solve с обязательным numerical profile. Stable uncertainty +/// возвращается data-вариантом `Indeterminate`, не ошибкой и не fallback. +/// +/// # Errors +/// +/// [`ColorError::InvalidGlowRequest`] на недоменном публичном input. +/// [`ColorError::IncompatibleCoreContract`] если core отверг уже проверенный +/// request либо FFI-adapter получил illegal/unsupported/unknown outcome, +/// который нельзя представить этой версией Glow-specific поверхности. +#[uniffi::export] +pub fn solve_glow_point( + tint: String, + background: String, + target_dj: f64, + theme: Theme, + profile: GlowDecisionProfile, +) -> Result { + validate_glow_request(&tint, &background, target_dj)?; + let core_profile = profile.to_core(); + let decision = map_prevalidated_glow_core_result(solve_screen_alpha_for_dj( + &tint, + &background, + target_dj, + core_profile, + &theme.vc(), + ))?; + match decision { + NumericalDecisionV1::Determinate { value, guarantee } => { + let kind = glow_determinate_kind( + profile, + guarantee, + value.status(), + value.selection_diagnostic_profile(), + )?; + let value = glow_point_value_to_ffi(&value)?; + match kind { + GlowDeterminateKind::StableExactNoop => { + Ok(GlowPointDecision::StableExactNoop { value }) + } + GlowDeterminateKind::LegacyReached => { + Ok(GlowPointDecision::LegacyReached { value }) + } + GlowDeterminateKind::LegacyUnreachable => { + Ok(GlowPointDecision::LegacyUnreachable { value }) + } + } + } + NumericalDecisionV1::Indeterminate { site_id, evidence } => { + if profile != GlowDecisionProfile::StableV1 { + return Err(ColorError::IncompatibleCoreContract { + reason: "legacy Glow profile returned an Indeterminate core outcome" + .to_string(), + }); + } + Ok(GlowPointDecision::Indeterminate { + site_id: numerical_site_to_ffi(site_id)?, + evidence: indeterminacy_to_ffi(evidence)?, + }) + } + _ => Err(incompatible_core_variant("NumericalDecisionV1")), + } +} + +/// Замороженная legacy-координата `muddiness` для цвета. +/// +/// Это `experimental compatibility proxy`: функция воспроизводит исторический +/// числовой API, но не является валидированным на наблюдателях человеческим +/// вердиктом clean/dirty и не должна использоваться как production decision. +/// Legacy-идентификатор сохранён только для совместимости. /// /// # Errors /// @@ -326,6 +662,176 @@ pub fn muddiness(hex: String) -> Result { mod tests { use super::*; + #[test] + fn illegal_glow_provenance_cross_product_is_rejected() { + let error = glow_determinate_kind( + GlowDecisionProfile::StableV1, + DecisionGuaranteeV1::LegacyPlatformDependentV1, + CoreGlowTargetStatus::LegacyReached, + Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + ) + .unwrap_err(); + assert!(matches!( + error, + ColorError::IncompatibleCoreContract { ref reason } + if reason.contains("illegal Glow determinate provenance") + )); + } + + #[test] + fn prevalidated_core_glow_error_is_not_reclassified_as_public_input() { + let error = map_prevalidated_glow_core_result::<()>(Err("synthetic core drift".into())) + .unwrap_err(); + assert!(matches!( + error, + ColorError::IncompatibleCoreContract { ref reason } + if reason.contains("synthetic core drift") + )); + } + + #[test] + fn stable_noop_output_is_an_atomic_variant() { + let decision = solve_glow_point( + "#010000".into(), + "#FE0000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert!(matches!( + decision, + GlowPointDecision::StableExactNoop { .. } + )); + } + + #[test] + fn known_unreachable_code_mapping_is_fallible_without_code_drift() { + use labcolors_core::Unreachable as U; + let cases = [ + ( + U::BelowContrastFloor { target: 1.0 }, + "below_contrast_floor", + ), + ( + U::ExceedsRange { + target: 100.0, + max_achievable: 90.0, + }, + "exceeds_range", + ), + ( + U::QuantizationGap { + target: 50.0, + nearest: 49.0, + }, + "quantization_gap", + ), + ( + U::FloorUnreachable { + floor: 4.5, + max_ratio: 4.0, + }, + "floor_unreachable", + ), + (U::PolarityMismatch { target: -60.0 }, "polarity_mismatch"), + (U::GamutUnsupported, "gamut_unsupported"), + (U::InvalidInput("fixture".into()), "invalid_input"), + ]; + for (error, expected) in cases { + assert_eq!(unreachable_code(&error).unwrap(), expected); + } + + let internal = U::InternalInvariant("fixture drift".into()); + assert!(matches!( + unreachable_code(&internal), + Err(ColorError::IncompatibleCoreContract { reason }) + if reason == "internal core invariant failure: fixture drift" + )); + } + + #[test] + fn glow_output_sum_is_exhaustive_over_lawful_provenance_states() { + fn branch_name(decision: &GlowPointDecision) -> &'static str { + match decision { + GlowPointDecision::StableExactNoop { .. } => "stable-exact-noop", + GlowPointDecision::LegacyReached { .. } => "legacy-reached", + GlowPointDecision::LegacyUnreachable { .. } => "legacy-unreachable", + GlowPointDecision::Indeterminate { .. } => "indeterminate", + } + } + + let decision = solve_glow_point( + "#010000".into(), + "#FE0000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert_eq!( + branch_name(&decision), + "stable-exact-noop", + "exhaustive match обязан читать реальный public output" + ); + } + + #[test] + fn unsupported_outward_decision_guarantee_is_an_incompatible_core_contract() { + let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); + let error = glow_determinate_kind( + GlowDecisionProfile::StableV1, + DecisionGuaranteeV1::OutwardIntervalV1(interval), + CoreGlowTargetStatus::ExactNoopUnreachable, + None, + ) + .unwrap_err(); + assert!(matches!( + error, + ColorError::IncompatibleCoreContract { ref reason } + if reason.contains("illegal Glow determinate provenance") + )); + + // Outward evidence остаётся законной частью typed Indeterminate: + // запрещена только ложная Glow-specific determinate guarantee. + assert_eq!( + indeterminacy_to_ffi(NumericalIndeterminacyV1::IntervalOverlap(interval)).unwrap(), + NumericalIndeterminacy::IntervalOverlap { + lower: 0.9, + upper: 1.1, + } + ); + + let unknown = incompatible_core_variant("FutureGlowContractV2"); + assert!(matches!( + unknown, + ColorError::IncompatibleCoreContract { ref reason } + if reason.contains("FutureGlowContractV2") + )); + } + + #[test] + fn invalid_public_glow_inputs_remain_invalid_glow_requests() { + for (tint, background, target_dj) in [ + ("not-a-color", "#000000", 2.3006), + ("#C0B2FA", "not-a-color", 2.3006), + ("#C0B2FA", "#000000", 0.0), + ("#C0B2FA", "#000000", -1.0), + ("#C0B2FA", "#000000", f64::NAN), + ("#C0B2FA", "#000000", f64::INFINITY), + ] { + let error = solve_glow_point( + tint.into(), + background.into(), + target_dj, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap_err(); + assert!(matches!(error, ColorError::InvalidGlowRequest { .. })); + } + } + #[test] fn contrast_black_on_white_is_21_wcag() { let c = contrast("#000000".into(), "#FFFFFF".into(), Theme::Light).unwrap(); @@ -388,13 +894,118 @@ mod tests { } #[test] - fn muddiness_orders_olive_above_clean_gray() { - // Значения — факт ядра (см. conformance/vectors/muddiness.json): серый - // почти чист, олива заметно мутнее. Проверяем ПОРЯДОК и чистоту серого, - // не магическое число. - let olive = muddiness("#6B6B2E".into()).unwrap(); - let gray = muddiness("#808080".into()).unwrap(); - assert!(gray < 0.05, "серый должен быть чистым, получено {gray}"); - assert!(olive > gray, "олива должна быть мутнее серого"); + fn muddiness_boundary_matches_frozen_core_coordinate() { + for hex in ["#6B6B2E", "#808080", "#007AFF", "#8A7A50"] { + let boundary = muddiness(hex.into()).expect("valid public input"); + let core = muddiness_from_hex(hex).expect("valid core input"); + assert_eq!( + boundary.to_bits(), + core.to_bits(), + "FFI boundary drifted for {hex}" + ); + } + + assert!(matches!( + muddiness("not-a-colour".into()), + Err(ColorError::InvalidColor { .. }) + )); + } + + #[test] + fn glow_provenance_is_encoded_by_atomic_output_variants() { + let stable = solve_glow_point( + "#C0B2FA".into(), + "#000000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert!(matches!( + stable, + GlowPointDecision::Indeterminate { + site_id: NumericalSiteId::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacy::SoundBoundUnavailable, + } + )); + + let legacy = solve_glow_point( + "#C0B2FA".into(), + "#000000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::LegacyPlatformDependentV1, + ) + .unwrap(); + assert!(matches!( + legacy, + GlowPointDecision::LegacyReached { value } + if value.composite_profile == GlowCompositeProfile::EncodedSrgb8ScreenV1 + && value.composite_guarantee == GlowCompositeGuarantee::BitExact + )); + + let stable_noop = solve_glow_point( + "#C0B2FA".into(), + "#FFFFFF".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert!(matches!( + stable_noop, + GlowPointDecision::StableExactNoop { value } + if value.composite_hex == "#FFFFFF" + )); + + let legacy_unreachable = solve_glow_point( + "#C0B2FA".into(), + "#FFFFFF".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::LegacyPlatformDependentV1, + ) + .unwrap(); + assert!(matches!( + legacy_unreachable, + GlowPointDecision::LegacyUnreachable { value } + if value.composite_hex == "#FFFFFF" + )); + } + + #[test] + fn stable_glow_noop_uses_the_quantised_endpoint_not_a_white_special_case() { + let sub_lsb = solve_glow_point( + "#010000".into(), + "#FE0000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert!(matches!( + sub_lsb, + GlowPointDecision::StableExactNoop { value } + if value.composite_profile == GlowCompositeProfile::EncodedSrgb8ScreenV1 + && value.composite_guarantee == GlowCompositeGuarantee::BitExact + && value.achieved_dj.to_bits() == 0.0_f64.to_bits() + && value.composite_hex == "#FE0000" + )); + + let crossing = solve_glow_point( + "#800000".into(), + "#FE0000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::StableV1, + ) + .unwrap(); + assert!(matches!( + crossing, + GlowPointDecision::Indeterminate { + site_id: NumericalSiteId::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacy::SoundBoundUnavailable, + } + )); } } diff --git a/crates/labcolors-preview/Cargo.toml b/crates/labcolors-preview/Cargo.toml index c7f8be3b..112f27e0 100644 --- a/crates/labcolors-preview/Cargo.toml +++ b/crates/labcolors-preview/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "labcolors-preview" +publish = false version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true [dependencies] labcolors-core = { path = "../labcolors-core" } diff --git a/crates/labcolors-wasm/Cargo.toml b/crates/labcolors-wasm/Cargo.toml index 56eef89b..081519b5 100644 --- a/crates/labcolors-wasm/Cargo.toml +++ b/crates/labcolors-wasm/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "labcolors-wasm" +publish = false version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true description = "WASM bindings for the labcolors-core contrast engine, packaged as @labpics/colors." [lib] @@ -29,6 +31,7 @@ serde_json = "1" [dev-dependencies] wasm-bindgen-test = "0.3" +labcolors-conformance = { path = "../labcolors-conformance" } # Native criterion bench of the solve cache-MISS path (`benches/resolve.rs`), # over the real labui config via the same load_config path. Mirrors # labcolors-core's `forward` bench. dev-only — never in the WASM bundle. diff --git a/crates/labcolors-wasm/LICENSE b/crates/labcolors-wasm/LICENSE new file mode 120000 index 00000000..30cff740 --- /dev/null +++ b/crates/labcolors-wasm/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/labcolors-wasm/src/config_dto.rs b/crates/labcolors-wasm/src/config_dto.rs index 8fa7bdaa..edfd076f 100644 --- a/crates/labcolors-wasm/src/config_dto.rs +++ b/crates/labcolors-wasm/src/config_dto.rs @@ -164,6 +164,7 @@ pub enum RoleRecipeDto { Glow { source: LadderSourceDto, step: String, + decision_profile: String, }, PairFill { source: LadderSourceDto, @@ -333,10 +334,16 @@ impl TryFrom for RoleRecipe { fn try_from(r: RoleRecipeDto) -> Result { Ok(match r { - RoleRecipeDto::Glow { source, step } => RoleRecipe::Glow { + RoleRecipeDto::Glow { + source, + step, + decision_profile, + } => RoleRecipe::Glow { source: source.into(), step: labcolors_core::glow::GlowStep::parse(&step) .map_err(|bad| format!("roles.*.step: неизвестная ступень glow `{bad}` (ожидается subtle|base|bloom)"))?, + decision_profile: labcolors_core::GlowDecisionProfileV1::parse(&decision_profile) + .map_err(|bad| format!("roles.*.decision_profile: неизвестный профиль glow `{bad}` (ожидается stable-v1|legacy-platform-dependent-v1)"))?, }, RoleRecipeDto::TextAnchor { fraction, @@ -502,9 +509,14 @@ impl TryFrom<&RoleRecipe> for RoleRecipeDto { fn try_from(r: &RoleRecipe) -> Result { Ok(match r { - RoleRecipe::Glow { source, step } => RoleRecipeDto::Glow { + RoleRecipe::Glow { + source, + step, + decision_profile, + } => RoleRecipeDto::Glow { source: source.try_into()?, step: step.key().to_string(), + decision_profile: decision_profile.key().to_string(), }, RoleRecipe::TextAnchor { fraction, @@ -745,6 +757,45 @@ mod tests { assert!(re.contains(r#""floor":"aa-text""#), "пол цел: {re}"); } + #[test] + fn glow_recipe_requires_and_fingerprints_explicit_decision_profile() { + let missing = r#"{"kind":"glow","source":{"kind":"brand"},"step":"base"}"#; + assert!( + serde_json::from_str::(missing).is_err(), + "implicit legacy/default profile запрещён schema-границей" + ); + + let legacy = r#"{"kind":"glow","source":{"kind":"brand"},"step":"base","decision_profile":"legacy-platform-dependent-v1"}"#; + let dto: RoleRecipeDto = serde_json::from_str(legacy).expect("explicit legacy парсится"); + let core = RoleRecipe::try_from(dto).expect("known profile компилируется"); + assert!(matches!( + core, + RoleRecipe::Glow { + decision_profile: labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + .. + } + )); + + let mut stable = labui_dto(); + let role = stable + .roles + .iter_mut() + .find(|role| matches!(role.recipe, RoleRecipeDto::Glow { .. })) + .expect("anti-vacuum: в паспорте есть glow"); + let RoleRecipeDto::Glow { + decision_profile, .. + } = &mut role.recipe + else { + unreachable!("find выше сузил variant") + }; + *decision_profile = "stable-v1".to_string(); + assert_ne!( + fingerprint(&labui_dto()), + fingerprint(&stable), + "decision profile обязан входить в canonical config identity" + ); + } + /// Отпечаток: детерминирован для одного конфига (включая нормализацию /// пробелов/порядка через парсинг) и различает разные конфиги. #[test] @@ -782,14 +833,16 @@ mod tests { /// 0.276 → 0.97335917/0.64359014/0.47572199/0.29335999, инвариант переноса — /// принятые владельцем hex'ы #141414/#767676/#C2C2C2). Это ЛЕГИТИМНАЯ смена /// паспорта (ADR помечает её semver-major), потому пин обновлён - /// 5013ba77a61f58ff → f2a892a62f7bc91e. Прочая структура (снятие роли `icon` - /// каноном #92) уже была в main до этой главы — дельта только в долях. + /// 5013ba77a61f58ff → f2a892a62f7bc91e. Глава #282 добавила обязательный + /// `decision_profile` в каждый Glow recipe: explicit legacy сохраняет + /// прежнюю эмиссию, но profile обязан менять cache/config identity, поэтому + /// пин легитимно стал c51445fcd167781a. #[test] fn full_labui_fingerprint_pin_current_main() { let full = labui_dto(); assert_eq!( format!("{:016x}", fingerprint(&full)), - "f2a892a62f7bc91e", + "c51445fcd167781a", "пин паспорта main; при легитимной смене паспорта обнови это число" ); } diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index e2e2c958..c9e88620 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -36,8 +36,8 @@ pub struct RoleEntry { pub outcome: RoleOutcome, } -/// The four honest outcomes of resolving a role, mirroring the core's -/// `Resolved` without leaking the core type across the boundary. +/// The honest outcome union for one role, mirroring the core's `Resolved` +/// without leaking the core type across the boundary. #[derive(Debug, Clone, PartialEq)] pub enum RoleOutcome { /// A solved colour with its measured contrasts and degradation flags. @@ -53,6 +53,8 @@ pub enum RoleOutcome { /// `mix-blend-mode: screen`; `--lab-` несёт halo, `--lab--core` /// — слой пересвета, `--lab--alpha` — интенсивность числом. Glow(GlowColor), + /// Stable Glow terminal result: no sound numerical decision, no CSS vars. + GlowIndeterminate(GlowIndeterminateColor), /// Двухслойный материал (kind material, #89): тинт `01` (с выведенной α) + /// опаковая база `02`, обе — один тон. `--lab--01` несёт /// `oklch( / α)`, `--lab--02` и `--lab-` — `oklch()` @@ -70,36 +72,85 @@ pub enum RoleOutcome { /// Слои свечения и решённая интенсивность (kind glow). #[derive(Debug, Clone, PartialEq)] pub struct GlowColor { - /// Слой пересвета (малый радиус), `#RRGGBB`. + /// Core-слой, предназначенный потребителем для меньшего blur, `#RRGGBB`; + /// сам движок геометрию не моделирует. pub core_hex: String, - /// Слой ореола — источник, `#RRGGBB`. + /// Halo-слой, предназначенный потребителем для большего blur, `#RRGGBB`; + /// в recipe v1 равен источнику. pub halo_hex: String, /// Интенсивность screen-слоя `(0, 1]`. pub alpha: f64, - /// Фактический |ΔJ'| композита от фона. - pub achieved_dj: f64, - /// Цель недостижима — ближайший достижимый шаг (ADR-0002, закон 2). - pub degraded: bool, + /// Каноническая CSS-запись той же alpha без повторного округления. + pub alpha_css: String, + /// Целевой |ΔJ′| изолированного halo-композита. + pub target_dj: f64, + /// Exact point-composite profile, независимо от target/max decision. + pub composite_profile: labcolors_core::GlowCompositeProfileV1, + /// Exact point-composite guarantee. + pub composite_guarantee: labcolors_core::GlowCompositeGuaranteeV1, + /// Версионированный алгоритм, построивший анатомию core/halo. + pub layer_recipe_profile: labcolors_core::GlowLayerRecipeProfileV1, + /// Диагностика внешнего вида полного результата Glow. Обязательна, потому что + /// `core_achieved_dj` реально вычисляется через CAM16-UCS J′. + pub appearance_diagnostic_profile: labcolors_core::GlowDiagnosticProfileV1, + /// Диагностическая модель, участвовавшая именно в выборе target/max. `None` у + /// точного no-op профиля stable, который не выполняет выбор по внешнему виду. + pub selection_diagnostic_profile: Option, + /// Explicit client-selected decision profile. + pub decision_profile: labcolors_core::GlowDecisionProfileV1, + /// Guarantee target/max decision. + pub decision_guarantee: labcolors_core::DecisionGuaranteeV1, + /// Слой, по которому решалась цель. + pub constraint_layer: labcolors_core::GlowConstraintLayer, + /// Типизированный результат target-проверки. + pub target_status: labcolors_core::GlowTargetStatus, + /// Reference-композит изолированного halo. + pub halo_composite_hex: String, + /// Фактический |ΔJ′| изолированного halo-композита. + pub halo_achieved_dj: f64, + /// Reference-композит изолированного core с той же alpha. + pub core_composite_hex: String, + /// Фактический |ΔJ′| изолированного core-композита. + pub core_achieved_dj: f64, +} + +/// Stable Glow result without a selected semantic state. +#[derive(Debug, Clone, PartialEq)] +pub struct GlowIndeterminateColor { + /// Canonical source anchor; not emitted as CSS without a decision. + pub source_hex: String, + /// Requested diagnostic target. + pub target_dj: f64, + /// Explicit stable profile. + pub decision_profile: labcolors_core::GlowDecisionProfileV1, + /// Registered branch-sensitive site. + pub site_id: labcolors_core::NumericalSiteIdV1, + /// Неразделимая причина вместе с sound interval, если он существует. + pub evidence: labcolors_core::NumericalIndeterminacyV1, + /// Layer whose target could not be classified. + pub constraint_layer: labcolors_core::GlowConstraintLayer, } /// Двухслойный материал (kind material): тон + выведенная α + вердикт гарантии. /// /// Тинт `01`, база `02` и солид-канон равны тону по построению (композит `T` над -/// `T` есть `T`), поэтому один `tone_hex`. `alpha` выведена как минимальная -/// плотность, при которой композит тона над худшим фоном коридора держит `floor`. +/// `T` есть `T`), поэтому один `tone_hex`. `alpha` — повторно проверенная верхняя +/// граница платформенно охарактеризованного поиска; численное свидетельство +/// лежит рядом. #[derive(Debug, Clone, PartialEq)] pub struct MaterialColor { /// Тон `#RRGGBB`: тинт `01`, база `02` и солид-канон одновременно. pub tone_hex: String, - /// Выведенная альфа тинта `01`, `(0, 1]`. + /// Выбранная альфа тинта `01`, `[0, 1]`. pub alpha: f64, /// Худший WCAG-контраст коммит-полюса по коридору `[чёрный, белый]`. pub worst_contrast: f64, - /// WCAG-пол читаемости, который держит α (4.5 / 3.0). + /// Платформенно охарактеризованное свидетельство выбора границы alpha. + pub alpha_guarantee: labcolors_core::MaterialAlphaGuaranteeV1, + /// Типизированный исход проверки пола; от него выведен булев псевдоним совместимости. + pub alpha_status: labcolors_core::MaterialAlphaStatusV1, + /// Запрошенный WCAG-пол (4.5 / 3.0); держится только при `Satisfied`. pub floor: f64, - /// Гарантия выполнена: `worst_contrast ≥ floor`. `false` — пол недостижим - /// даже при α = 1 (честная деградация, α = 1 как ближайшая достижимая). - pub guaranteed: bool, /// Коммит-полюс поверхности белый (`true`, тёмный тон) или чёрный (`false`). pub pole_white: bool, /// Фактический |ΔJ'| тона-базы от фона резолва — различимость поверхности. @@ -125,13 +176,11 @@ pub struct RgbaColor { pub composite_lc: f64, /// The WCAG 2.1 ratio of the composite. pub composite_wcag: f64, - /// `true` when the requested alpha was raised to the smallest resolvable - /// value (`α_min`) because the requested transparency is not reproducible in - /// gamut — an honest, flagged degradation of the role contract (mirrors - /// `SolvedColor::compressed` / `GlowColor::degraded`). The colour never lies: - /// the composite still equals the target solid byte-for-byte; only the - /// alpha carried in `alpha` differs from what was asked. Always `false` for a - /// direct ladder emission. + /// `true`, если запрошенная alpha не допускает ни одного byte-тинта, + /// воспроизводящего цель в affine encoded-sRGB8 reference, и потому поднята + /// до первого проходящего `binary64`. Композит остаётся побайтно равен + /// целевому солиду; меняется только явно возвращённая `alpha`. У прямой + /// лестницы всегда `false`. pub alpha_coerced: bool, /// `true` when a solid family border (`border--strong`, M2 ch5c) was /// darkened along the family curve to meet the AA UI floor (3:1), because the diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 99c5118d..fb28b388 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -39,7 +39,7 @@ const CACHE_CAPACITY: usize = 4096; /// bump, not a re-clone of the whole set. pub struct Engine { named: Option, - cache: ContractCache>, + cache: ContractCache, BindingError>>, } /// Загруженный конфиг потребителя: скомпилированная таблица + её отпечаток @@ -116,8 +116,9 @@ impl Engine { /// Resolve every role for `bg_hex` under `theme`, returning the shared /// result. Repeated identical calls hit the contract cache. /// - /// Errors (bad hex, unknown theme) are returned, never - /// panicked. Per-role unreachability is part of a *successful* result. + /// Errors (bad hex, unknown theme, or an internal core invariant failure) + /// are returned, never panicked. Physical per-role unreachability is part + /// of a *successful* result; internal provenance fails the whole call. pub fn resolve_theme( &self, bg_hex: &str, @@ -139,14 +140,14 @@ impl Engine { let set = labcolors_core::resolve_named_set(&bg, &named.table, &vc); let mut roles: Vec = set .into_iter() - .map(|(name, resolved)| { + .map(|(name, resolved)| -> Result { let floor = named.floors.get(&name).copied().flatten(); - RoleEntry { + Ok(RoleEntry { role_key: name, - outcome: map_resolved(resolved, floor), - } + outcome: map_resolved(resolved, floor)?, + }) }) - .collect(); + .collect::>()?; // Алиасы — часть эмитируемого контракта (--lab-{alias} обязан // существовать у потребителя): ядро их не резолвит (алиас — не // рецепт), граница эмитит исход ЦЕЛИ под именем алиаса. @@ -159,13 +160,13 @@ impl Engine { }); } } - Rc::new(ResolvedTheme { + Ok(Rc::new(ResolvedTheme { theme: theme.key(), background: normalised.clone(), roles, - }) + })) }); - return Ok(result); + return result; } // Agnostic engine: no config, nothing to emit. An honest, matchable @@ -243,8 +244,8 @@ impl Engine { /// Map one core [`Resolved`] into the boundary [`RoleOutcome`]. `legal_floor` is /// the role's WCAG clamp (from the role table), carried onto a solved colour. -fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { - match resolved { +fn map_resolved(resolved: Resolved, legal_floor: Option) -> Result { + Ok(match resolved { Resolved::Color { solved, compressed, @@ -259,7 +260,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { )), Resolved::None => RoleOutcome::None, Resolved::Unreachable(reason) => RoleOutcome::Unreachable { - code: unreachable_code(&reason), + code: unreachable_code(&reason)?, message: reason.to_string(), }, // Полупрозрачная эмиссия лестницы/альфа-аналога (конфиг-путь): @@ -279,30 +280,56 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { core_hex: g.core_hex().to_string(), halo_hex: g.halo_hex().to_string(), alpha: g.alpha(), - achieved_dj: g.achieved_dj(), - degraded: g.degraded(), + alpha_css: g.alpha_css().to_string(), + target_dj: g.target_dj(), + composite_profile: g.composite_profile(), + composite_guarantee: g.halo_composite_certificate().guarantee(), + layer_recipe_profile: g.layer_recipe_profile(), + appearance_diagnostic_profile: g.appearance_diagnostic_profile(), + selection_diagnostic_profile: g.selection_diagnostic_profile(), + decision_profile: g.decision_profile(), + decision_guarantee: g.decision_guarantee(), + constraint_layer: g.constraint_layer(), + target_status: g.target_status(), + halo_composite_hex: g.halo_composite_hex().to_string(), + halo_achieved_dj: g.halo_achieved_dj(), + core_composite_hex: g.core_composite_hex().to_string(), + core_achieved_dj: g.core_achieved_dj(), }), + Resolved::GlowIndeterminate(g) => { + RoleOutcome::GlowIndeterminate(crate::dto::GlowIndeterminateColor { + source_hex: g.source_hex().to_string(), + target_dj: g.target_dj(), + decision_profile: g.decision_profile(), + site_id: g.site_id(), + evidence: g.evidence(), + constraint_layer: g.constraint_layer(), + }) + } // Двухслойный материал (#89): тинт 01 (oklch/α) + опаковая база 02. Resolved::Material(m) => RoleOutcome::Material(crate::dto::MaterialColor { tone_hex: m.tint_hex().to_string(), alpha: m.alpha(), worst_contrast: m.worst_contrast(), + alpha_guarantee: m.alpha_guarantee(), + alpha_status: m.alpha_status(), floor: m.floor(), - guaranteed: m.guaranteed(), pole_white: matches!(m.pole(), labcolors_core::Pole::White), achieved_dj: m.achieved_dj(), tone_compressed: m.tone_compressed(), hue_vanished: m.hue_vanished(), distinct: m.distinct(), }), - // ОСОЗНАННЫЙ ДОЛГ: `Resolved` — `#[non_exhaustive]`, поэтому catch-all - // обязателен для будущих вариантов ядра. Пока маппит в стабильный код, - // а не молча роняет неверный цвет; при экспорте rgba-границы каждый - // новый вариант должен получить явный арм выше, а не оседать сюда. - _ => RoleOutcome::Unreachable { - code: "unreachable", - message: "unmapped resolved variant".to_string(), - }, + // `Resolved` is non-exhaustive across this crate boundary. An unknown + // future semantic outcome is a structural adapter failure, never a + // physically-looking `Unreachable` role. + _ => return Err(unmapped_resolved_variant()), + }) +} + +fn unmapped_resolved_variant() -> BindingError { + BindingError::Internal { + reason: "unmapped core Resolved variant".to_string(), } } @@ -329,18 +356,18 @@ fn map_solved( /// of the JS-facing contract — a caller may branch on them — so they must not /// change silently (see `unreachable_codes_are_the_stable_js_contract`). /// -/// `Unreachable` is `#[non_exhaustive]`, so the catch-all is mandatory and -/// honest: a core variant we have not mapped yet reports `"unreachable"` rather -/// than failing to compile against a future core. Known variants get a specific -/// code so a JS caller can branch on the cause. +/// `Unreachable` is `#[non_exhaustive]`, so the catch-all is mandatory. A future +/// core variant is a structural adapter failure until this closed wire vocabulary +/// maps it explicitly; it must never collapse into a physically-looking generic +/// `Unreachable` result. /// /// Note: with the v1 default role table, `resolve_theme` never actually yields /// an unreachable role on any solid background (a wide sweep finds none) — every /// default role is reachable everywhere. This mapping is therefore a forward- /// compatible / defensive seam, exercised below by driving the core `solve` /// directly into the cases a custom table or a future gamut would surface. -fn unreachable_code(reason: &Unreachable) -> &'static str { - match reason { +fn unreachable_code(reason: &Unreachable) -> Result<&'static str, BindingError> { + let code = match reason { Unreachable::BelowContrastFloor { .. } => "below_contrast_floor", Unreachable::ExceedsRange { .. } => "exceeds_range", Unreachable::QuantizationGap { .. } => "quantization_gap", @@ -348,7 +375,19 @@ fn unreachable_code(reason: &Unreachable) -> &'static str { Unreachable::PolarityMismatch { .. } => "polarity_mismatch", Unreachable::GamutUnsupported => "gamut_unsupported", Unreachable::InvalidInput(_) => "invalid_input", - _ => "unreachable", + Unreachable::InternalInvariant(reason) => { + return Err(BindingError::Internal { + reason: reason.clone(), + }); + } + _ => return Err(unmapped_unreachable_reason()), + }; + Ok(code) +} + +fn unmapped_unreachable_reason() -> BindingError { + BindingError::Internal { + reason: "unmapped core Unreachable variant".to_string(), } } @@ -360,7 +399,7 @@ fn unreachable_code(reason: &Unreachable) -> &'static str { /// needs no canonical (upper-cased, `#`-led) key. Validation is preserved: a /// 6-length non-hex body falls through to [`normalise_hex`], which rejects it /// with the same message; other lengths likewise route through `normalise_hex`. -fn hex_for_recheck(raw: &str) -> Result, BindingError> { +pub(crate) fn hex_for_recheck(raw: &str) -> Result, BindingError> { let body = raw.strip_prefix('#').unwrap_or(raw); if body.len() == 6 && body.bytes().all(|c| c.is_ascii_hexdigit()) { return Ok(Cow::Borrowed(raw)); @@ -371,7 +410,7 @@ fn hex_for_recheck(raw: &str) -> Result, BindingError> { /// Normalise a background hex to the canonical `#RRGGBB` upper-case form the /// cache keys on. Accepts `#`-led or bare, 3- or 6-digit; rejects anything else /// with the core's own parse vocabulary so the message matches `BgInput::solid`. -fn normalise_hex(raw: &str) -> Result { +pub(crate) fn normalise_hex(raw: &str) -> Result { let body = raw.strip_prefix('#').unwrap_or(raw); let expanded = match body.len() { 3 => body.chars().flat_map(|c| [c, c]).collect::(), @@ -394,6 +433,38 @@ fn normalise_hex(raw: &str) -> Result { mod tests { use super::*; + #[test] + fn hot_recheck_hex_path_borrows_six_digit_inputs() { + assert!(matches!( + hex_for_recheck("#C0B2FA").unwrap(), + Cow::Borrowed("#C0B2FA") + )); + assert!(matches!( + hex_for_recheck("c0b2fa").unwrap(), + Cow::Borrowed("c0b2fa") + )); + assert!(matches!(hex_for_recheck("#CBF").unwrap(), Cow::Owned(_))); + } + + #[test] + fn unknown_core_outcome_is_a_structural_boundary_error() { + let error = unmapped_resolved_variant(); + assert!(matches!( + error, + BindingError::Internal { ref reason } + if reason == "unmapped core Resolved variant" + )); + assert_eq!(error.code(), "internal_error"); + + let error = unmapped_unreachable_reason(); + assert!(matches!( + error, + BindingError::Internal { ref reason } + if reason == "unmapped core Unreachable variant" + )); + assert_eq!(error.code(), "internal_error"); + } + /// An engine with the frozen labui passport loaded — the config the built-in /// default table used to hardcode. The agnostic engine has no built-in system, /// so every resolve test drives a real loaded contract. @@ -532,7 +603,7 @@ mod tests { Gamut::DisplayP3, ) .unwrap_err(); - assert_eq!(unreachable_code(&gamut_err), "gamut_unsupported"); + assert_eq!(unreachable_code(&gamut_err).unwrap(), "gamut_unsupported"); // A non-finite target is rejected up front as invalid input. let invalid_err = solve( @@ -544,7 +615,14 @@ mod tests { Gamut::Srgb, ) .unwrap_err(); - assert_eq!(unreachable_code(&invalid_err), "invalid_input"); + assert_eq!(unreachable_code(&invalid_err).unwrap(), "invalid_input"); + + let internal = Unreachable::InternalInvariant("fixture drift".into()); + let error = unreachable_code(&internal).unwrap_err(); + assert!(matches!( + error, + BindingError::Internal { reason } if reason == "fixture drift" + )); } #[test] @@ -716,6 +794,12 @@ mod tests { entry.role_key ); } + RoleOutcome::GlowIndeterminate(g) => { + assert_eq!( + g.decision_profile, + labcolors_core::GlowDecisionProfileV1::StableV1 + ); + } RoleOutcome::Material(m) => { assert!( m.tone_hex.starts_with('#') && m.tone_hex.len() == 7, @@ -942,7 +1026,46 @@ mod tests { assert_eq!(g.core_hex(), o.core_hex, "{name}: glow core"); assert_eq!(g.halo_hex(), o.halo_hex, "{name}: glow halo"); assert_eq!(g.alpha(), o.alpha, "{name}: glow alpha"); - assert_eq!(g.degraded(), o.degraded, "{name}: glow degraded"); + assert_eq!(g.alpha_css(), o.alpha_css, "{name}: glow alpha_css"); + assert_eq!(g.target_dj(), o.target_dj, "{name}: glow target_dj"); + assert_eq!(g.composite_profile(), o.composite_profile); + assert_eq!( + g.halo_composite_certificate().guarantee(), + o.composite_guarantee + ); + assert_eq!(g.layer_recipe_profile(), o.layer_recipe_profile); + assert_eq!( + g.appearance_diagnostic_profile(), + o.appearance_diagnostic_profile + ); + assert_eq!( + g.selection_diagnostic_profile(), + o.selection_diagnostic_profile + ); + assert_eq!(g.decision_profile(), o.decision_profile); + assert_eq!(g.decision_guarantee(), o.decision_guarantee); + assert_eq!(g.constraint_layer(), o.constraint_layer); + assert_eq!(g.target_status(), o.target_status); + assert_eq!(g.halo_composite_hex(), o.halo_composite_hex); + assert_eq!(g.halo_achieved_dj(), o.halo_achieved_dj); + assert_eq!(g.core_composite_hex(), o.core_composite_hex); + assert_eq!(g.core_achieved_dj(), o.core_achieved_dj); + assert_eq!( + g.degraded(), + matches!( + o.target_status, + labcolors_core::GlowTargetStatus::ExactNoopUnreachable + | labcolors_core::GlowTargetStatus::LegacyUnreachable + ) + ); + assert_eq!(g.achieved_dj().to_bits(), o.halo_achieved_dj.to_bits()); + } + (Resolved::GlowIndeterminate(g), RoleOutcome::GlowIndeterminate(o)) => { + assert_eq!(g.source_hex(), o.source_hex); + assert_eq!(g.target_dj(), o.target_dj); + assert_eq!(g.decision_profile(), o.decision_profile); + assert_eq!(g.site_id(), o.site_id); + assert_eq!(g.evidence(), o.evidence); } (Resolved::Material(m), RoleOutcome::Material(o)) => { assert_eq!(m.tint_hex(), o.tone_hex, "{name}: material tone"); @@ -953,7 +1076,16 @@ mod tests { "{name}: material worst_contrast" ); assert_eq!(m.floor(), o.floor, "{name}: material floor"); - assert_eq!(m.guaranteed(), o.guaranteed, "{name}: material guaranteed"); + assert_eq!( + m.alpha_status(), + o.alpha_status, + "{name}: material alpha status" + ); + assert_eq!( + m.guaranteed(), + o.alpha_status == labcolors_core::MaterialAlphaStatusV1::Satisfied, + "{name}: material guaranteed compatibility alias" + ); assert_eq!( matches!(m.pole(), labcolors_core::Pole::White), o.pole_white, @@ -1033,4 +1165,239 @@ mod tests { let set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); assert!(set.roles.iter().any(|r| r.role_key == "accent-fill")); } + + #[test] + fn stable_glow_is_typed_indeterminate_and_emits_no_vars() { + let stable_json = labui_json().replacen("legacy-platform-dependent-v1", "stable-v1", 1); + let mut engine = Engine::new(); + engine + .load_config(&stable_json) + .expect("stable profile валиден"); + let resolved = engine + .resolve_theme("#101012", Theme::Dark) + .expect("resolve возвращает per-role terminal outcomes"); + let role = resolved + .roles + .iter() + .find(|entry| entry.role_key == "fx-glow-brand") + .expect("stable glow role присутствует"); + assert!(matches!( + role.outcome, + RoleOutcome::GlowIndeterminate(ref g) + if g.site_id == labcolors_core::NumericalSiteIdV1::GlowTargetOrMaximumV1 + && g.evidence + == labcolors_core::NumericalIndeterminacyV1::SoundBoundUnavailable + )); + + let projected = crate::projection::resolved_json(&resolved).unwrap(); + let value: serde_json::Value = serde_json::from_str(&projected).unwrap(); + assert_eq!( + value["roles"]["fx-glow-brand"]["kind"], + "glow-indeterminate" + ); + assert!( + value["vars"].get("--lab-fx-glow-brand").is_none() + && value["vars"].get("--lab-fx-glow-brand-core").is_none() + && value["vars"].get("--lab-fx-glow-brand-alpha").is_none(), + "Indeterminate не имеет права эмитить legacy fallback vars" + ); + } + + #[test] + fn stable_glow_exact_noop_is_bit_exact_across_wasm_projection() { + let stable_json = labui_json().replacen("legacy-platform-dependent-v1", "stable-v1", 1); + let mut engine = Engine::new(); + engine + .load_config(&stable_json) + .expect("stable profile валиден"); + let resolved = engine + .resolve_theme("#FFFFFF", Theme::Light) + .expect("white screen point is an exact no-op"); + let role = resolved + .roles + .iter() + .find(|entry| entry.role_key == "fx-glow-brand") + .expect("stable glow role присутствует"); + assert!(matches!( + role.outcome, + RoleOutcome::Glow(ref g) + if g.decision_profile == labcolors_core::GlowDecisionProfileV1::StableV1 + && g.decision_guarantee == labcolors_core::DecisionGuaranteeV1::BitExact + && g.halo_composite_hex == "#FFFFFF" + && g.core_composite_hex == "#FFFFFF" + )); + + let projected: serde_json::Value = + serde_json::from_str(&crate::projection::resolved_json(&resolved).unwrap()).unwrap(); + let glow = &projected["roles"]["fx-glow-brand"]; + assert_eq!(glow["kind"], "glow"); + assert_eq!(glow["decisionProfile"], "stable-v1"); + assert_eq!(glow["decisionGuarantee"]["kind"], "bit-exact"); + assert_eq!(glow["targetStatus"], "exact-noop-unreachable"); + assert_eq!(glow["layerRecipeProfile"], "cam16-jprime-oklab-cusp-v1"); + assert_eq!( + glow["appearanceDiagnosticProfile"], "cam16-ucs-jprime-li2017-v1", + "full Glow computes coreAchievedDj through CAM16" + ); + assert_eq!( + glow["selectionDiagnosticProfile"], + serde_json::Value::Null, + "exact no-op selection does not execute CAM16" + ); + assert_eq!(glow["haloCompositeHex"], "#FFFFFF"); + for key in [ + "--lab-fx-glow-brand", + "--lab-fx-glow-brand-core", + "--lab-fx-glow-brand-alpha", + ] { + assert!(projected["vars"].get(key).is_some(), "missing {key}"); + } + } + + /// `None` остаётся полноценным client-owned токеном в metadata, но ни сама + /// zero-роль, ни алиас на неё не получают CSS-значение. Это anti-vacuum + /// проверка ровно той границы, которую namespace preflight защищает. + #[test] + fn none_roles_and_aliases_publish_metadata_but_never_values() { + let mut engine = Engine::new(); + engine.load_config(&labui_json()).expect("labui валиден"); + let resolved = engine + .resolve_theme("#101012", Theme::Dark) + .expect("валидный контракт резолвится"); + let projected: serde_json::Value = + serde_json::from_str(&crate::projection::resolved_json(&resolved).unwrap()).unwrap(); + + for name in ["none", "border-none", "fill-none", "border-ghost"] { + let css_var = format!("--lab-{name}"); + let role = &projected["roles"][name]; + assert_eq!(role["kind"], "none", "{name} обязан остаться none"); + assert_eq!(role["cssVar"], css_var, "{name} сохраняет своё имя"); + assert!( + projected["vars"].get(&css_var).is_none(), + "{name}: none-токен не имеет права получить CSS-значение" + ); + } + } + + /// Differential contract: берём фактические satellite keys из WASM- + /// проекции валидных Glow/Material outcomes и убеждаемся, что публичный + /// `load_config` не разрешает объявить ни zero-роль, ни zero-alias с любым + /// из этих имён. Тест не дублирует список суффиксов валидатора как oracle. + #[test] + fn load_config_rejects_zero_names_for_every_projected_satellite() { + let cases = [ + ( + "probe-glow", + serde_json::json!({ + "kind": "glow", + "source": {"kind": "brand"}, + "step": "base", + "decision_profile": "legacy-platform-dependent-v1" + }), + ), + ( + "probe-material", + serde_json::json!({ + "kind": "material", + "source": {"kind": "neutral", "pick": "mid"}, + "tone_light": 10.0, + "tone_dark": 10.0, + "floor": "aa-text" + }), + ), + ]; + + for (owner, recipe) in cases { + let mut valid: serde_json::Value = + serde_json::from_str(&labui_json()).expect("паспорт labui — валидный JSON"); + valid["roles"] + .as_array_mut() + .expect("roles — массив") + .push(serde_json::json!({"name": owner, "recipe": recipe})); + + let mut engine = Engine::new(); + engine + .load_config(&valid.to_string()) + .expect("контрольный многоключевой рецепт валиден"); + let resolved = engine + .resolve_theme("#101012", Theme::Dark) + .expect("контрольный рецепт резолвится"); + let projected: serde_json::Value = + serde_json::from_str(&crate::projection::resolved_json(&resolved).unwrap()) + .unwrap(); + let prefix = format!("--lab-{owner}-"); + let satellites: Vec = projected["vars"] + .as_object() + .expect("vars — объект") + .keys() + .filter(|key| key.starts_with(&prefix)) + .cloned() + .collect(); + assert_eq!(satellites.len(), 2, "{owner}: ожидаются два сателлита"); + + for css_key in satellites { + let zero_name = css_key + .strip_prefix("--lab-") + .expect("проекция использует канонический prefix"); + + for as_alias in [false, true] { + let mut colliding = valid.clone(); + if as_alias { + colliding["aliases"] + .as_array_mut() + .expect("aliases — массив") + .push(serde_json::json!({ + "alias": zero_name, + "target": "none" + })); + } else { + colliding["roles"] + .as_array_mut() + .expect("roles — массив") + .push(serde_json::json!({ + "name": zero_name, + "recipe": {"kind": "zero"} + })); + } + + let mut candidate = Engine::new(); + match candidate.load_config(&colliding.to_string()) { + Err(BindingError::InvalidConfig { reason }) => { + assert!(reason.contains(&css_key), "{reason}"); + assert!(reason.contains("reserved CSS namespace"), "{reason}"); + } + other => panic!( + "{css_key}: zero {} обязан быть отвергнут, получено {other:?}", + if as_alias { "alias" } else { "role" } + ), + } + } + } + } + } + + /// Публичная JSON-граница обязана применять полный namespace-preflight + /// ядра: иначе алиас мог затереть числовой `-alpha` цветовой строкой уже в + /// `vars`, хотя оба отдельных имени выглядели валидными. + #[test] + fn load_config_rejects_alias_colliding_with_glow_satellite() { + let mut value: serde_json::Value = + serde_json::from_str(&labui_json()).expect("паспорт labui — валидный JSON"); + value["aliases"] + .as_array_mut() + .expect("aliases — массив") + .push(serde_json::json!({ + "alias": "fx-glow-brand-alpha", + "target": "label-primary" + })); + + let mut engine = Engine::new(); + match engine.load_config(&value.to_string()) { + Err(BindingError::InvalidConfig { reason }) => { + assert!(reason.contains("--lab-fx-glow-brand-alpha"), "{reason}"); + assert!(reason.contains("reserved CSS namespace"), "{reason}"); + } + other => panic!("коллизия производного имени обязана быть отвергнута: {other:?}"), + } + } } diff --git a/crates/labcolors-wasm/src/error.rs b/crates/labcolors-wasm/src/error.rs index 9cabb76a..bcb1c37a 100644 --- a/crates/labcolors-wasm/src/error.rs +++ b/crates/labcolors-wasm/src/error.rs @@ -25,6 +25,13 @@ pub enum BindingError { reason: String, }, + /// A non-background colour argument was not valid encoded sRGB hex. + #[error("invalid colour: {reason}")] + InvalidColor { + /// The core parse/domain reason. + reason: String, + }, + /// The config JSON was rejected: parse error, unknown menu item, or a /// core validation/compile error (the full preflight message is carried). #[error("invalid config: {reason}")] @@ -40,13 +47,13 @@ pub enum BindingError { #[error("no config loaded: call load_config before resolve_theme")] ConfigRequired, - /// A resolved colour failed to serialise into the oklch emission form. - /// Unreachable by construction (solver/ladder hexes are valid) — carried - /// as a structured code so JS branching stays uniform even for the - /// impossible branch. + /// A core-generated value violated an internal postcondition or the adapter + /// could not represent a known/forward core variant without losing meaning. + /// Includes projection/oklch serialization failures and stable-Glow recheck + /// failures after public inputs were already validated. Never client blame. #[error("internal error: {reason}")] Internal { - /// What exactly failed to serialise. + /// The internal postcondition, projection, or contract mismatch. reason: String, }, @@ -64,6 +71,7 @@ impl BindingError { pub fn code(&self) -> &'static str { match self { BindingError::InvalidBackground { .. } => "invalid_background", + BindingError::InvalidColor { .. } => "invalid_color", BindingError::InvalidConfig { .. } => "invalid_config", BindingError::ConfigRequired => "config_required", BindingError::UnknownTheme { .. } => "unknown_theme", @@ -80,6 +88,7 @@ mod tests { fn codes_are_stable_and_distinct() { let errors = [ BindingError::InvalidBackground { reason: "x".into() }, + BindingError::InvalidColor { reason: "x".into() }, BindingError::InvalidConfig { reason: "x".into() }, BindingError::ConfigRequired, BindingError::UnknownTheme { @@ -92,6 +101,7 @@ mod tests { codes, [ "invalid_background", + "invalid_color", "invalid_config", "config_required", "unknown_theme", diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index c90980c1..67de634c 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -30,7 +30,7 @@ use std::rc::Rc; use wasm_bindgen::prelude::*; use crate::dto::ResolvedTheme; -use crate::engine::Engine; +use crate::engine::{Engine, hex_for_recheck}; use crate::error::BindingError; /// TypeScript shapes for the values `resolveTheme` returns. wasm-bindgen emits @@ -109,12 +109,11 @@ export interface TranslucentRole { /** WCAG 2.1 ratio of the composite. */ readonly compositeWcag: number; /** - * `true` when the requested alpha was raised to the smallest resolvable value - * (α_min) because the requested transparency is not reproducible in gamut — an - * honest, flagged contract degradation (mirrors `SolvedColor.compressed` / - * `GlowRole.degraded`). The colour never lies: the composite still equals the - * target solid byte-for-byte; only `alpha` differs from what was asked. Always - * `false` for a direct ladder emission. + * `true`, если запрошенная alpha не допускает ни одного byte-тинта, + * воспроизводящего цель в affine encoded-sRGB8 reference, и потому поднята до + * первого проходящего `binary64`. Композит остаётся побайтно равен целевому + * солиду; меняется только явно возвращённая `alpha`. У прямой лестницы всегда + * `false`. */ readonly alphaCoerced: boolean; /** @@ -132,41 +131,164 @@ export interface TranslucentRole { /** Свечение (kind glow, labui ADR-0002 §5): screen-слои + решённая интенсивность. * Потребитель красит слои с mix-blend-mode: screen; `vars` несёт * --lab- (halo, oklch), --lab--core и --lab--alpha. */ -export interface GlowRole { +export type GlowDecisionProfileV1 = "stable-v1" | "legacy-platform-dependent-v1"; +export type GlowLayerRecipeProfileV1 = "cam16-jprime-oklab-cusp-v1"; +export type GlowDiagnosticProfileV1 = "cam16-ucs-jprime-li2017-v1"; +export type GlowTargetStatusV1 = + | "exact-noop-unreachable" + | "legacy-reached" + | "legacy-unreachable"; +export type NumericalIndeterminacyV1 = + | { + readonly reason: "sound-bound-unavailable"; + readonly bounds: { readonly kind: "unavailable" }; + } + | { + readonly reason: "interval-overlap"; + readonly bounds: { + readonly kind: "outward"; + readonly lower: number; + readonly upper: number; + }; + }; +export interface GlowBitExactDecisionGuaranteeV1 { + readonly kind: "bit-exact"; +} +export interface GlowLegacyDecisionGuaranteeV1 { + readonly kind: "legacy-platform-dependent-v1"; +} +export type GlowDecisionGuaranteeV1 = + | GlowBitExactDecisionGuaranteeV1 + | GlowLegacyDecisionGuaranteeV1; + +/** Поля, общие для трёх конструктивно допустимых определённых исходов Glow. */ +export interface GlowDeterminateRoleBase { readonly kind: "glow"; readonly cssVar: string; - /** Слой пересвета (малый радиус), #RRGGBB. */ + /** Core, предназначенный потребителем для меньшего blur; геометрия не моделируется. */ readonly coreHex: string; - /** Слой ореола — источник, #RRGGBB. */ + /** Halo, предназначенный потребителем для большего blur; в recipe v1 это источник. */ readonly haloHex: string; /** Интенсивность screen-слоя, (0, 1]. */ readonly alpha: number; - /** Фактический |ΔJ'| композита от фона. */ + /** Каноническая CSS-запись той же alpha; vars использует буквально её. */ + readonly alphaCss: string; + /** Exact reference-домен point-композита; не renderer/display certificate. */ + readonly compositeProfile: "encoded-srgb8-screen-v1"; + readonly compositeGuarantee: "bit-exact"; + /** Версионированный алгоритм, построивший анатомию core/halo. */ + readonly layerRecipeProfile: GlowLayerRecipeProfileV1; + /** Модель внешнего вида полного результата; обязательна, потому coreAchievedDj вычислен через CAM16. */ + readonly appearanceDiagnosticProfile: GlowDiagnosticProfileV1; + /** Цель решается только по изолированному halo. */ + readonly constraintLayer: "halo"; + /** Запрошенный |ΔJ'| halo-композита. */ + readonly targetDj: number; + /** Reference-композит изолированного halo на фоне резолва. */ + readonly haloCompositeHex: string; + /** Фактический |ΔJ'| изолированного halo-композита. */ + readonly haloAchievedDj: number; + /** Reference-композит изолированного core с той же alpha. */ + readonly coreCompositeHex: string; + /** Фактический |ΔJ'| изолированного core-композита. */ + readonly coreAchievedDj: number; + /** @deprecated Alias `haloAchievedDj` для совместимости. */ readonly achievedDj: number; - /** Цель недостижима — ближайший достижимый шаг (ADR-0002, закон 2). */ - readonly degraded: boolean; - /** Ready-to-serve CSS value халo: "oklch(L% C H)". */ + /** CSS-значение halo: `oklch(L% C H)`. */ readonly css: string; } +/** Точный no-op: выбор не вызывает CAM16 и не может достигнуть положительную цель. */ +export interface GlowStableExactNoopRole extends GlowDeterminateRoleBase { + readonly decisionProfile: "stable-v1"; + readonly decisionGuarantee: GlowBitExactDecisionGuaranteeV1; + readonly selectionDiagnosticProfile: null; + readonly targetStatus: "exact-noop-unreachable"; + /** @deprecated Выведено из targetStatus. */ + readonly degraded: true; +} + +/** Legacy-выбор CAM16 достиг целевого |ΔJ'| в охарактеризованной среде исполнения. */ +export interface GlowLegacyReachedRole extends GlowDeterminateRoleBase { + readonly decisionProfile: "legacy-platform-dependent-v1"; + readonly decisionGuarantee: GlowLegacyDecisionGuaranteeV1; + readonly selectionDiagnosticProfile: GlowDiagnosticProfileV1; + readonly targetStatus: "legacy-reached"; + /** @deprecated Выведено из targetStatus. */ + readonly degraded: false; +} + +/** Legacy-выбор CAM16 вернул максимум, не достигший целевого |ΔJ'|. */ +export interface GlowLegacyUnreachableRole extends GlowDeterminateRoleBase { + readonly decisionProfile: "legacy-platform-dependent-v1"; + readonly decisionGuarantee: GlowLegacyDecisionGuaranteeV1; + readonly selectionDiagnosticProfile: GlowDiagnosticProfileV1; + readonly targetStatus: "legacy-unreachable"; + /** @deprecated Выведено из targetStatus. */ + readonly degraded: true; +} + +export type GlowDeterminateRole = + | GlowStableExactNoopRole + | GlowLegacyReachedRole + | GlowLegacyUnreachableRole; + +/** Stable terminal outcome: no sound target/max decision, therefore no CSS vars. */ +export interface GlowIndeterminateRoleBase { + readonly kind: "glow-indeterminate"; + readonly cssVar: string; + readonly sourceHex: string; + readonly targetDj: number; + readonly constraintLayer: "halo"; + readonly decisionProfile: "stable-v1"; + readonly numericalSiteId: "glow-target-or-maximum-v1"; +} + +export type GlowIndeterminateRole = GlowIndeterminateRoleBase & NumericalIndeterminacyV1; + +export type GlowRole = GlowDeterminateRole | GlowIndeterminateRole; + +/** Численное свидетельство выбора material-alpha. Оно характеризует повторно + * проверенный результат профиля, а не строгую межплатформенную границу + * минимальной alpha. */ +export interface MaterialAlphaGuaranteeBaseV1 { + /** Byte-scale affine binary64 compositor + original WCAG 2.1 (2018) + * `0.03928` EOTF, with a conservative channel envelope and both crossed seam + * sides. Platform-characterized because `powf` is not outward-bounded. */ + readonly numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1"; +} +/** Rechecked fail/pass endpoints from a fixed-step binary partition after the + * directed-search guard. No global-monotonicity, first-state or minimum claim. */ +export interface MaterialBisectionBracketGuaranteeV1 extends MaterialAlphaGuaranteeBaseV1 { + readonly kind: "bisection-bracket-characterized-v1"; + readonly iterations: number; + readonly lowerAlpha: number; + readonly upperAlpha: number; +} +export interface MaterialTransparentEndpointGuaranteeV1 extends MaterialAlphaGuaranteeBaseV1 { + readonly kind: "transparent-endpoint-characterized-v1"; +} +export interface MaterialOpaqueEndpointGuaranteeV1 extends MaterialAlphaGuaranteeBaseV1 { + readonly kind: "opaque-endpoint-characterized-v1"; +} +export type MaterialAlphaGuaranteeV1 = + | MaterialBisectionBracketGuaranteeV1 + | MaterialTransparentEndpointGuaranteeV1 + | MaterialOpaqueEndpointGuaranteeV1; + /** Двухслойный материал (kind material, #89): тинт `01` (с выведенной α) над * опаковой базой `02`, обе — один тон. `vars` несёт --lab- (солид-канон, * опаковый), --lab--01 (тинт oklch/α) и --lab--02 (база, опаковая). * Композит-гарантия читаемости пересчитываема из toneHex/alpha (α-граница). */ -export interface MaterialRole { +export interface MaterialRoleBase { readonly kind: "material"; readonly cssVar: string; /** Тон #RRGGBB: тинт 01, база 02 и солид-канон одновременно (равны). */ readonly toneHex: string; - /** Выведенная альфа тинта 01, (0, 1]. */ - readonly alpha: number; /** Худший WCAG-контраст коммит-полюса по коридору [чёрный, белый]. */ readonly worstContrast: number; - /** WCAG-пол читаемости, который держит α (4.5 / 3.0). */ + /** Запрошенный WCAG-пол (4.5 / 3.0); держится только при `alphaStatus: "satisfied"`. */ readonly floor: number; - /** Гарантия выполнена: worstContrast ≥ floor. `false` — пол недостижим даже - * при α = 1 (честная деградация, α = 1 как ближайшая достижимая). */ - readonly guaranteed: boolean; /** Коммит-полюс поверхности белый (true, тёмный тон) или чёрный (false). */ readonly poleWhite: boolean; /** Фактический |ΔJ'| тона-базы от фона — различимость поверхности. */ @@ -181,6 +303,35 @@ export interface MaterialRole { readonly css: string; } +/** Уже прозрачная граничная точка держит запрошенный пол. */ +export interface MaterialSatisfiedTransparentRole extends MaterialRoleBase { + readonly alpha: 0; + readonly alphaGuarantee: MaterialTransparentEndpointGuaranteeV1; + readonly alphaStatus: "satisfied"; + readonly guaranteed: true; +} + +/** Повторно проверенная верхняя граница интервала держит запрошенный пол. */ +export interface MaterialSatisfiedBracketRole extends MaterialRoleBase { + readonly alpha: number; + readonly alphaGuarantee: MaterialBisectionBracketGuaranteeV1; + readonly alphaStatus: "satisfied"; + readonly guaranteed: true; +} + +/** Даже непрозрачная граничная точка не держит запрошенный пол; возвращается alpha 1. */ +export interface MaterialDegradedOpaqueRole extends MaterialRoleBase { + readonly alpha: 1; + readonly alphaGuarantee: MaterialOpaqueEndpointGuaranteeV1; + readonly alphaStatus: "degraded"; + readonly guaranteed: false; +} + +export type MaterialRole = + | MaterialSatisfiedTransparentRole + | MaterialSatisfiedBracketRole + | MaterialDegradedOpaqueRole; + export type RoleResult = | SolvedColor | TranslucentRole @@ -204,13 +355,56 @@ export type LadderSource = | { kind: "sentiment"; name: string } | { kind: "neutral"; pick: "mid" | "edge" | "inverted" | "light" | "dark" }; +/** Closed physical ladder menu accepted by the config compiler. */ +export type LadderPositionV1 = + | "label-primary" + | "label-secondary" + | "label-tertiary" + | "label-quaternary" + | "fill-primary" + | "fill-secondary" + | "fill-tertiary" + | "fill-quaternary" + | "border-base" + | "border-soft" + | "border-strong" + | "focus-ring" + | "glow" + | "skeleton-base" + | "skeleton-highlight" + | "neutral-fill-primary" + | "neutral-fill-secondary" + | "neutral-fill-tertiary" + | "neutral-fill-quaternary" + | "neutral-border-base" + | "neutral-border-soft" + | "shadow-minor" + | "shadow-ambient" + | "shadow-penumbra" + | "shadow-major"; + /** Рецепт роли из физического меню движка. */ export type RoleRecipe = - | { kind: "text-anchor"; fraction: number; floor: "aa-text" | "aa-ui" | "none" } + | { + kind: "text-anchor"; + fraction: number; + floor: "aa-text" | "aa-ui" | "none"; + hue?: LadderSource; + } | { kind: "dj-anchor"; light: number; dark: number } | { kind: "decorative-lc"; magnitude: number } - | { kind: "ladder"; source: LadderSource; position: string } - | { kind: "glow"; source: LadderSource; step: "subtle" | "base" | "bloom" } + | { + kind: "ladder"; + source: LadderSource; + position: LadderPositionV1; + floor?: "aa-text" | "aa-ui" | "none"; + } + | { + kind: "glow"; + source: LadderSource; + step: "subtle" | "base" | "bloom"; + decision_profile: GlowDecisionProfileV1; + } | { kind: "pair-fill"; source: LadderSource } | { kind: "pair-label"; source: LadderSource; fraction: number; floor: "aa-text" | "aa-ui" | "none" } | { kind: "alpha-analog"; of: LadderSource; alpha: number } @@ -245,7 +439,7 @@ export interface ThemeConfig { readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>; /** Словарь ролей дизайн-системы. Конфиг обязан нести собственные роли; пустой * контракт (без `roles` и `aliases`) отклоняется на загрузке. */ - readonly roles?: ReadonlyArray<{ name: string; recipe: RoleRecipe }>; + readonly roles: ReadonlyArray<{ name: string; recipe: RoleRecipe }>; readonly aliases?: ReadonlyArray<{ alias: string; target: string }>; } @@ -313,9 +507,9 @@ impl LabColors { /// Returns a `ResolvedTheme` object. Per-role unreachability is part of a /// successful result (each role carries its own `kind`); only whole-call /// failures reject (no config loaded yet as `config_required`, invalid hex, - /// unknown theme, and the by-construction-unreachable oklch serialisation - /// failure as `internal_error`) — as a structured `": "` - /// error, never an unwound panic. + /// unknown theme, a core invariant failure, and the by-construction- + /// unreachable oklch serialisation failure as `internal_error`) — as a + /// structured `": "` error, never an unwound panic. #[wasm_bindgen(js_name = resolveTheme)] pub fn resolve_theme(&self, bg_hex: &str, theme: &str) -> Result { let theme = crate::theme::parse_theme(theme).map_err(to_js_error)?; @@ -388,11 +582,34 @@ impl LabColors { .map_err(to_js_error) } - /// Calculate the muddiness score (0 to 1) of an sRGB hex colour. + /// Exact stable-Glow runtime recheck. Returns whether the point screen + /// layer is a byte-for-byte no-op for every alpha under encoded sRGB8. + /// `adaptTheme` uses this cheap core-owned predicate to detect the only + /// stable determinate/indeterminate class transition without running CAM16 + /// or re-solving on every animated-background frame. + #[wasm_bindgen(js_name = isStableGlowPointNoop)] + pub fn is_stable_glow_point_noop(&self, tint_hex: &str, bg_hex: &str) -> Result { + let tint = hex_for_recheck(tint_hex).map_err(|error| match error { + BindingError::InvalidBackground { reason } => { + to_js_error(BindingError::InvalidColor { reason }) + } + other => to_js_error(other), + })?; + let background = hex_for_recheck(bg_hex).map_err(to_js_error)?; + labcolors_core::screen_point_is_exact_noop(tint.as_ref(), background.as_ref()) + .map_err(|reason| to_js_error(stable_glow_recheck_core_error(reason))) + } + + /// Return the frozen legacy `muddiness` coordinate for an sRGB hex colour. + /// + /// This is an experimental compatibility proxy: it reproduces the historic + /// numeric API, but is not an observer-validated human clean/dirty verdict + /// or a production decision. The legacy identifier is retained only for + /// compatibility. #[wasm_bindgen(js_name = muddiness)] pub fn muddiness(&self, hex: &str) -> Result { labcolors_core::cleanliness::muddiness_from_hex(hex) - .map_err(|reason| to_js_error(BindingError::InvalidBackground { reason })) + .map_err(|reason| to_js_error(BindingError::InvalidColor { reason })) } /// Recheck one foreground set against MANY background samples in a single @@ -437,24 +654,78 @@ fn to_js_error(err: BindingError) -> JsError { JsError::new(&format!("{}: {}", err.code(), err)) } -#[cfg(all(test, target_arch = "wasm32"))] -mod tests { +fn stable_glow_recheck_core_error(reason: String) -> BindingError { + BindingError::Internal { + reason: format!( + "stable Glow point recheck rejected core-generated validated hex values: {reason}" + ), + } +} + +#[cfg(test)] +mod native_contract_tests { use super::*; #[test] - fn test_wasm_muddiness() { - let colors = LabColors::new(); - - // Olive is highly muddy - let olive_mud = colors.muddiness("#6B6B2E").unwrap(); - assert!(olive_mud > 0.80); - assert!((olive_mud - 0.8699).abs() < 1e-3); + fn generated_config_types_cover_the_closed_ladder_menu_and_dto_fields() { + let source = include_str!("lib.rs"); + let types = source + .split_once("const TS_RESULT_TYPES: &'static str = r##\"") + .and_then(|(_, tail)| tail.split_once("\"##;").map(|(types, _)| types)) + .expect("custom TypeScript section is extractable"); + let block = types + .split_once("export type LadderPositionV1 =") + .and_then(|(_, tail)| tail.split_once(';').map(|(block, _)| block)) + .expect("LadderPositionV1 union exists"); + let declared: Vec<&str> = block + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("| \"") + .and_then(|value| value.strip_suffix('"')) + }) + .collect(); + let declared_set: std::collections::HashSet<&str> = declared.iter().copied().collect(); + let core_set: std::collections::HashSet<&str> = labcolors_core::LadderPosition::ALL + .iter() + .map(|position| position.key()) + .collect(); + assert_eq!( + declared.len(), + declared_set.len(), + "duplicate TS ladder literal" + ); + assert_eq!(declared_set, core_set, "TS ladder menu must equal core ALL"); + assert!(types.contains("hue?: LadderSource")); + assert!(types.contains("floor?: \"aa-text\" | \"aa-ui\" | \"none\"")); + assert!(types.contains("readonly roles: ReadonlyArray")); + } - // Gray is clean - let gray_mud = colors.muddiness("#808080").unwrap(); - assert!(gray_mud < 0.05); + #[test] + fn stable_glow_noop_boundary_normalises_the_resolve_hex_vocabulary() { + let colors = LabColors::new(); + assert_eq!( + colors.is_stable_glow_point_noop("#001", "fff").unwrap(), + colors + .is_stable_glow_point_noop("#000011", "#FFFFFF") + .unwrap() + ); + assert!( + colors + .is_stable_glow_point_noop("#010000", "#FE0000") + .unwrap() + ); + assert!( + !colors + .is_stable_glow_point_noop("#800000", "#FE0000") + .unwrap() + ); + } - // Invalid hex returns an error - assert!(colors.muddiness("not_a_hex").is_err()); + #[test] + fn stable_glow_core_failure_after_boundary_validation_is_internal() { + let error = stable_glow_recheck_core_error("fixture drift".into()); + assert!(matches!(error, BindingError::Internal { .. })); + assert_eq!(error.code(), "internal_error"); } } diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index df14f38f..db1314a6 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -25,7 +25,7 @@ use std::fmt::Write as _; -use crate::dto::{ResolvedTheme, RoleOutcome}; +use crate::dto::{GlowColor, GlowIndeterminateColor, MaterialColor, ResolvedTheme, RoleOutcome}; use crate::error::BindingError; /// Сериализовать [`ResolvedTheme`] в JSON, литерально повторяющий форму @@ -77,6 +77,7 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { push_var(&mut vars, &css_var, &css); } RoleOutcome::Glow(g) => { + let degraded = glow_degraded_from_provenance(g)?; // Свечение: слои для screen-наложения потребителем. // --lab- несёт halo (единая oklch-форма), сателлиты // --lab--core / --lab--alpha — анатомия и @@ -85,20 +86,108 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { field_str(&mut roles, "coreHex", &g.core_hex); field_str(&mut roles, "haloHex", &g.halo_hex); field_num(&mut roles, "alpha", g.alpha)?; - field_num(&mut roles, "achievedDj", g.achieved_dj)?; - field_bool(&mut roles, "degraded", g.degraded); + let canonical_alpha = + labcolors_core::css_alpha_value(g.alpha).map_err(|reason| { + BindingError::Internal { + reason: format!("glow alpha не сериализуется: {reason}"), + } + })?; + if canonical_alpha != g.alpha_css { + return Err(BindingError::Internal { + reason: format!( + "glow alphaCss рассинхронизирован: ожидался {canonical_alpha}, получен {}", + g.alpha_css + ), + }); + } + field_str(&mut roles, "alphaCss", &g.alpha_css); + field_str( + &mut roles, + "compositeProfile", + glow_composite_profile_key(g.composite_profile)?, + ); + field_str( + &mut roles, + "compositeGuarantee", + glow_composite_guarantee_key(g.composite_guarantee)?, + ); + field_str( + &mut roles, + "layerRecipeProfile", + glow_layer_recipe_profile_key(g.layer_recipe_profile)?, + ); + field_str( + &mut roles, + "appearanceDiagnosticProfile", + glow_diagnostic_profile_key(g.appearance_diagnostic_profile)?, + ); + let selection_diagnostic_profile = match g.selection_diagnostic_profile { + Some(profile) => Some(glow_diagnostic_profile_key(profile)?), + None => None, + }; + field_opt_str( + &mut roles, + "selectionDiagnosticProfile", + selection_diagnostic_profile, + ); + field_str( + &mut roles, + "decisionProfile", + glow_decision_profile_key(g.decision_profile)?, + ); + field_glow_decision_guarantee(&mut roles, g.decision_guarantee)?; + field_str( + &mut roles, + "constraintLayer", + glow_constraint_layer_key(g.constraint_layer)?, + ); + field_num(&mut roles, "targetDj", g.target_dj)?; + field_str( + &mut roles, + "targetStatus", + glow_target_status_key(g.target_status)?, + ); + field_str(&mut roles, "haloCompositeHex", &g.halo_composite_hex); + field_num(&mut roles, "haloAchievedDj", g.halo_achieved_dj)?; + field_str(&mut roles, "coreCompositeHex", &g.core_composite_hex); + field_num(&mut roles, "coreAchievedDj", g.core_achieved_dj)?; + // Aliases совместимости старого неоднозначного контракта. + field_num(&mut roles, "achievedDj", g.halo_achieved_dj)?; + field_bool(&mut roles, "degraded", degraded); let halo_css = oklch_css(&g.halo_hex, None)?; let core_css = oklch_css(&g.core_hex, None)?; field_str(&mut roles, "css", &halo_css); push_var(&mut vars, &css_var, &halo_css); push_var(&mut vars, &format!("{css_var}-core"), &core_css); - push_var( - &mut vars, - &format!("{css_var}-alpha"), - &format!("{:.4}", g.alpha), + push_var(&mut vars, &format!("{css_var}-alpha"), &g.alpha_css); + } + RoleOutcome::GlowIndeterminate(g) => { + validate_glow_indeterminate_provenance(g)?; + field_str(&mut roles, "kind", "glow-indeterminate"); + field_str(&mut roles, "sourceHex", &g.source_hex); + field_num(&mut roles, "targetDj", g.target_dj)?; + field_str( + &mut roles, + "constraintLayer", + glow_constraint_layer_key(g.constraint_layer)?, + ); + field_str( + &mut roles, + "decisionProfile", + glow_decision_profile_key(g.decision_profile)?, + ); + field_str( + &mut roles, + "numericalSiteId", + numerical_site_id_key(g.site_id)?, ); + fields_numerical_indeterminacy(&mut roles, g.evidence)?; + // Неопределённая stable-ветвь не эмитит переменные halo/core/alpha: + // вызывающий код получает типизированный терминальный исход, а не + // устаревший или выбранный платформой резервный цвет. } RoleOutcome::Material(m) => { + let guaranteed = material_guaranteed_from_provenance(m)?; // Материал (#89): тинт 01 (oklch/α) над опаковой базой 02 (oklch). // --lab- несёт солид-канон (= тон, опаковый) как SOLID- // фолбэк; --lab--01 — тинт со слэш-альфой; --lab--02 — @@ -107,8 +196,14 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { field_str(&mut roles, "toneHex", &m.tone_hex); field_num(&mut roles, "alpha", m.alpha)?; field_num(&mut roles, "worstContrast", m.worst_contrast)?; + field_material_alpha_guarantee(&mut roles, m.alpha_guarantee)?; + field_str( + &mut roles, + "alphaStatus", + material_alpha_status_key(m.alpha_status)?, + ); field_num(&mut roles, "floor", m.floor)?; - field_bool(&mut roles, "guaranteed", m.guaranteed); + field_bool(&mut roles, "guaranteed", guaranteed); field_bool(&mut roles, "poleWhite", m.pole_white); field_num(&mut roles, "achievedDj", m.achieved_dj)?; field_bool(&mut roles, "toneCompressed", m.tone_compressed); @@ -149,6 +244,294 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { Ok(out) } +fn unknown_output_variant(type_name: &str) -> BindingError { + BindingError::Internal { + reason: format!("проекция: неизвестный вариант {type_name}"), + } +} + +fn glow_degraded_from_provenance(glow: &GlowColor) -> Result { + match ( + glow.decision_profile, + glow.decision_guarantee, + glow.selection_diagnostic_profile, + glow.target_status, + ) { + ( + labcolors_core::GlowDecisionProfileV1::StableV1, + labcolors_core::DecisionGuaranteeV1::BitExact, + None, + labcolors_core::GlowTargetStatus::ExactNoopUnreachable, + ) => Ok(true), + ( + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + labcolors_core::GlowTargetStatus::LegacyReached, + ) => Ok(false), + ( + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + labcolors_core::GlowTargetStatus::LegacyUnreachable, + ) => Ok(true), + _ => Err(BindingError::Internal { + reason: "проекция: несогласованный Glow provenance".to_string(), + }), + } +} + +fn validate_glow_indeterminate_provenance( + glow: &GlowIndeterminateColor, +) -> Result<(), BindingError> { + match glow.decision_profile { + labcolors_core::GlowDecisionProfileV1::StableV1 => Ok(()), + _ => Err(BindingError::Internal { + reason: "проекция: несогласованный GlowIndeterminate provenance".to_string(), + }), + } +} + +fn material_guaranteed_from_provenance(material: &MaterialColor) -> Result { + match (material.alpha_status, material.alpha_guarantee) { + ( + labcolors_core::MaterialAlphaStatusV1::Satisfied, + labcolors_core::MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { .. }, + ) if material.alpha.to_bits() == 0.0_f64.to_bits() => Ok(true), + ( + labcolors_core::MaterialAlphaStatusV1::Satisfied, + labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + upper_alpha, + .. + }, + ) if material.alpha.to_bits() == upper_alpha.to_bits() => Ok(true), + ( + labcolors_core::MaterialAlphaStatusV1::Degraded, + labcolors_core::MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { .. }, + ) if material.alpha.to_bits() == 1.0_f64.to_bits() => Ok(false), + _ => Err(BindingError::Internal { + reason: "проекция: несогласованный Material provenance".to_string(), + }), + } +} + +fn glow_composite_profile_key( + profile: labcolors_core::GlowCompositeProfileV1, +) -> Result<&'static str, BindingError> { + match profile { + labcolors_core::GlowCompositeProfileV1::EncodedSrgb8ScreenV1 => { + Ok("encoded-srgb8-screen-v1") + } + _ => Err(unknown_output_variant("GlowCompositeProfileV1")), + } +} + +fn glow_composite_guarantee_key( + guarantee: labcolors_core::GlowCompositeGuaranteeV1, +) -> Result<&'static str, BindingError> { + match guarantee { + labcolors_core::GlowCompositeGuaranteeV1::BitExact => Ok("bit-exact"), + _ => Err(unknown_output_variant("GlowCompositeGuaranteeV1")), + } +} + +fn glow_layer_recipe_profile_key( + profile: labcolors_core::GlowLayerRecipeProfileV1, +) -> Result<&'static str, BindingError> { + match profile { + labcolors_core::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1 => { + Ok("cam16-jprime-oklab-cusp-v1") + } + _ => Err(unknown_output_variant("GlowLayerRecipeProfileV1")), + } +} + +fn glow_diagnostic_profile_key( + profile: labcolors_core::GlowDiagnosticProfileV1, +) -> Result<&'static str, BindingError> { + match profile { + labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1 => { + Ok("cam16-ucs-jprime-li2017-v1") + } + _ => Err(unknown_output_variant("GlowDiagnosticProfileV1")), + } +} + +fn glow_decision_profile_key( + profile: labcolors_core::GlowDecisionProfileV1, +) -> Result<&'static str, BindingError> { + match profile { + labcolors_core::GlowDecisionProfileV1::StableV1 => Ok("stable-v1"), + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1 => { + Ok("legacy-platform-dependent-v1") + } + _ => Err(unknown_output_variant("GlowDecisionProfileV1")), + } +} + +fn glow_target_status_key( + status: labcolors_core::GlowTargetStatus, +) -> Result<&'static str, BindingError> { + match status { + labcolors_core::GlowTargetStatus::ExactNoopUnreachable => Ok("exact-noop-unreachable"), + labcolors_core::GlowTargetStatus::LegacyReached => Ok("legacy-reached"), + labcolors_core::GlowTargetStatus::LegacyUnreachable => Ok("legacy-unreachable"), + _ => Err(unknown_output_variant("GlowTargetStatus")), + } +} + +fn glow_constraint_layer_key( + layer: labcolors_core::GlowConstraintLayer, +) -> Result<&'static str, BindingError> { + match layer { + labcolors_core::GlowConstraintLayer::Halo => Ok("halo"), + _ => Err(unknown_output_variant("GlowConstraintLayer")), + } +} + +fn numerical_site_id_key( + site_id: labcolors_core::NumericalSiteIdV1, +) -> Result<&'static str, BindingError> { + match site_id { + labcolors_core::NumericalSiteIdV1::GlowTargetOrMaximumV1 => Ok("glow-target-or-maximum-v1"), + _ => Err(unknown_output_variant("NumericalSiteIdV1")), + } +} + +fn material_alpha_status_key( + status: labcolors_core::MaterialAlphaStatusV1, +) -> Result<&'static str, BindingError> { + match status { + labcolors_core::MaterialAlphaStatusV1::Satisfied => Ok("satisfied"), + labcolors_core::MaterialAlphaStatusV1::Degraded => Ok("degraded"), + _ => Err(unknown_output_variant("MaterialAlphaStatusV1")), + } +} + +fn material_numerical_profile_key( + profile: labcolors_core::MaterialNumericalProfileV1, +) -> Result<&'static str, BindingError> { + match profile { + labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1 => { + Ok("encoded-srgb-byte-scale-affine-platform-binary64-powf-v1") + } + _ => Err(unknown_output_variant("MaterialNumericalProfileV1")), + } +} + +/// Поле сертификата определённого решения. Функция владеет всей вложенной +/// формой контракта, чтобы новый вариант сертификата не усложнял проекцию роли. +fn field_glow_decision_guarantee( + out: &mut String, + guarantee: labcolors_core::DecisionGuaranteeV1, +) -> Result<(), BindingError> { + let key = match guarantee { + labcolors_core::DecisionGuaranteeV1::BitExact => "bit-exact", + labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1 => { + "legacy-platform-dependent-v1" + } + labcolors_core::DecisionGuaranteeV1::OutwardIntervalV1(_) => { + return Err(BindingError::Internal { + reason: "проекция: неподдерживаемый Glow DecisionGuaranteeV1: outward-interval-v1" + .to_string(), + }); + } + _ => { + return Err(BindingError::Internal { + reason: "проекция: неподдерживаемый Glow DecisionGuaranteeV1".to_string(), + }); + } + }; + out.push_str(",\"decisionGuarantee\":{\"kind\":"); + push_str_lit(out, key); + out.push('}'); + Ok(()) +} + +/// Поле свидетельства выбора material-alpha. Функция владеет полной вложенной +/// формой контракта и каноническим порядком полей для интервальной и обеих +/// граничных ветвей. +fn field_material_alpha_guarantee( + out: &mut String, + guarantee: labcolors_core::MaterialAlphaGuaranteeV1, +) -> Result<(), BindingError> { + out.push_str(",\"alphaGuarantee\":{\"kind\":"); + match guarantee { + labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations, + lower_alpha, + upper_alpha, + numerical_profile, + } => { + push_str_lit(out, "bisection-bracket-characterized-v1"); + field_num(out, "iterations", f64::from(iterations))?; + field_num(out, "lowerAlpha", lower_alpha)?; + field_num(out, "upperAlpha", upper_alpha)?; + field_str( + out, + "numericalProfile", + material_numerical_profile_key(numerical_profile)?, + ); + } + labcolors_core::MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { + numerical_profile, + } => { + push_str_lit(out, "transparent-endpoint-characterized-v1"); + field_str( + out, + "numericalProfile", + material_numerical_profile_key(numerical_profile)?, + ); + } + labcolors_core::MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { + numerical_profile, + } => { + push_str_lit(out, "opaque-endpoint-characterized-v1"); + field_str( + out, + "numericalProfile", + material_numerical_profile_key(numerical_profile)?, + ); + } + _ => { + return Err(BindingError::Internal { + reason: "проекция: неизвестный MaterialAlphaGuaranteeV1".to_string(), + }); + } + } + out.push('}'); + Ok(()) +} + +/// Поля свидетельства для неопределённого решения. `reason` и связанный с ним +/// `bounds` эмитятся вместе: тип остаётся единственным источником их формы. +fn fields_numerical_indeterminacy( + out: &mut String, + evidence: labcolors_core::NumericalIndeterminacyV1, +) -> Result<(), BindingError> { + match evidence { + labcolors_core::NumericalIndeterminacyV1::SoundBoundUnavailable => { + field_str(out, "reason", "sound-bound-unavailable"); + out.push_str(",\"bounds\":{\"kind\":"); + push_str_lit(out, "unavailable"); + } + labcolors_core::NumericalIndeterminacyV1::IntervalOverlap(interval) => { + field_str(out, "reason", "interval-overlap"); + out.push_str(",\"bounds\":{\"kind\":"); + push_str_lit(out, "outward"); + field_num(out, "lower", interval.lower())?; + field_num(out, "upper", interval.upper())?; + } + _ => { + return Err(BindingError::Internal { + reason: "проекция: неизвестный NumericalIndeterminacyV1".to_string(), + }); + } + } + out.push('}'); + Ok(()) +} + /// Единая CSS-форма эмиссии: `oklch(L% C H)` / `oklch(L% C H / A)`. /// Байт-точность реконструкции доказана round-trip тестом ядра на решётке /// куба. Hex к этому месту валиден по построению (солвер/лестница), но при @@ -180,6 +563,19 @@ fn field_str(out: &mut String, name: &str, value: &str) { push_str_lit(out, value); } +/// Опциональная строка: `None` эмитится как явный `null`, чтобы отсутствие +/// выполненной диагностики нельзя было спутать с потерянным полем контракта. +fn field_opt_str(out: &mut String, name: &str, value: Option<&str>) { + match value { + Some(value) => field_str(out, name, value), + None => { + out.push_str(",\""); + out.push_str(name); + out.push_str("\":null"); + } + } +} + /// Поле-число. НЕ-конечное значение — нарушение инварианта солвера: честная /// ошибка вместо невалидного JSON или тихой подмены. fn field_num(out: &mut String, name: &str, value: f64) -> Result<(), BindingError> { @@ -237,7 +633,10 @@ fn push_str_lit(out: &mut String, s: &str) { #[cfg(test)] mod tests { use super::*; - use crate::dto::{GlowColor, ResolvedTheme, RgbaColor, RoleEntry, RoleOutcome, SolvedColor}; + use crate::dto::{ + GlowColor, GlowIndeterminateColor, MaterialColor, ResolvedTheme, RgbaColor, RoleEntry, + RoleOutcome, SolvedColor, + }; fn color_entry(key: &str) -> RoleEntry { RoleEntry { @@ -280,11 +679,31 @@ mod tests { RoleEntry { role_key: "pulse".to_string(), outcome: RoleOutcome::Glow(GlowColor { - core_hex: "#FFF1CC".to_string(), - halo_hex: "#FFB300".to_string(), - alpha: 0.5, - achieved_dj: 12.0625, - degraded: false, + core_hex: "#A0C5FF".to_string(), + halo_hex: "#4A8FFF".to_string(), + alpha: 0.036_045_459_685_627_89, + alpha_css: "0.03604545968562789".to_string(), + target_dj: 2.3006, + composite_profile: + labcolors_core::GlowCompositeProfileV1::EncodedSrgb8ScreenV1, + composite_guarantee: labcolors_core::GlowCompositeGuaranteeV1::BitExact, + layer_recipe_profile: + labcolors_core::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1, + appearance_diagnostic_profile: + labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, + selection_diagnostic_profile: Some( + labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, + ), + decision_profile: + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + decision_guarantee: + labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + constraint_layer: labcolors_core::GlowConstraintLayer::Halo, + target_status: labcolors_core::GlowTargetStatus::LegacyReached, + halo_composite_hex: "#13151B".to_string(), + halo_achieved_dj: 2.373_123_785_729_128, + core_composite_hex: "#15171B".to_string(), + core_achieved_dj: 3.235_504_076_619_437, }), }, RoleEntry { @@ -305,8 +724,8 @@ mod tests { let json = resolved_json(&fixture()).unwrap(); let css_label = labcolors_core::oklch_css_from_hex("#D5D5D7", None).unwrap(); let css_veil = labcolors_core::oklch_css_from_hex("#89CFF0", Some(0.35)).unwrap(); - let css_halo = labcolors_core::oklch_css_from_hex("#FFB300", None).unwrap(); - let css_core = labcolors_core::oklch_css_from_hex("#FFF1CC", None).unwrap(); + let css_halo = labcolors_core::oklch_css_from_hex("#4A8FFF", None).unwrap(); + let css_core = labcolors_core::oklch_css_from_hex("#A0C5FF", None).unwrap(); let expected = format!( concat!( "{{\"theme\":\"dark\",\"background\":\"#3A3A3C\",\"vars\":{{", @@ -314,7 +733,7 @@ mod tests { "\"--lab-veil\":\"{veil}\",", "\"--lab-pulse\":\"{halo}\",", "\"--lab-pulse-core\":\"{core}\",", - "\"--lab-pulse-alpha\":\"0.5000\"", + "\"--lab-pulse-alpha\":\"0.03604545968562789\"", "}},\"roles\":{{", "\"label-primary\":{{\"cssVar\":\"--lab-label-primary\",\"kind\":\"color\",", "\"hex\":\"#D5D5D7\",\"lc\":62.375,\"wcagRatio\":7.25,\"compressed\":false,", @@ -326,8 +745,19 @@ mod tests { "\"compositeLc\":-41.5,\"compositeWcag\":3.125,\"alphaCoerced\":true,", "\"floorCoerced\":false,\"css\":\"{veil}\"}},", "\"pulse\":{{\"cssVar\":\"--lab-pulse\",\"kind\":\"glow\",", - "\"coreHex\":\"#FFF1CC\",\"haloHex\":\"#FFB300\",\"alpha\":0.5,", - "\"achievedDj\":12.0625,\"degraded\":false,\"css\":\"{halo}\"}},", + "\"coreHex\":\"#A0C5FF\",\"haloHex\":\"#4A8FFF\",", + "\"alpha\":0.03604545968562789,\"alphaCss\":\"0.03604545968562789\",", + "\"compositeProfile\":\"encoded-srgb8-screen-v1\",", + "\"compositeGuarantee\":\"bit-exact\",", + "\"layerRecipeProfile\":\"cam16-jprime-oklab-cusp-v1\",", + "\"appearanceDiagnosticProfile\":\"cam16-ucs-jprime-li2017-v1\",", + "\"selectionDiagnosticProfile\":\"cam16-ucs-jprime-li2017-v1\",", + "\"decisionProfile\":\"legacy-platform-dependent-v1\",", + "\"decisionGuarantee\":{{\"kind\":\"legacy-platform-dependent-v1\"}},", + "\"constraintLayer\":\"halo\",\"targetDj\":2.3006,\"targetStatus\":\"legacy-reached\",", + "\"haloCompositeHex\":\"#13151B\",\"haloAchievedDj\":2.373123785729128,", + "\"coreCompositeHex\":\"#15171B\",\"coreAchievedDj\":3.235504076619437,", + "\"achievedDj\":2.373123785729128,\"degraded\":false,\"css\":\"{halo}\"}},", "\"impossible\":{{\"cssVar\":\"--lab-impossible\",\"kind\":\"unreachable\",", "\"code\":\"gamut_exhausted\",", "\"message\":\"нет цвета: \\\"предел\\\"\\nвторая строка\"}}", @@ -339,6 +769,221 @@ mod tests { core = css_core, ); assert_eq!(json, expected); + + // Anti-vacuum: `kind: none` публикует client-owned `cssVar` как + // метаданные контракта, но никогда не присваивает этому имени значение. + let projected: serde_json::Value = serde_json::from_str(&json).unwrap(); + let none = &projected["roles"]["spacer"]; + assert_eq!(none["kind"], "none"); + assert_eq!(none["cssVar"], "--lab-spacer"); + assert!(projected["vars"].get("--lab-spacer").is_none()); + } + + #[test] + fn interval_overlap_indeterminacy_survives_json_projection() { + let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); + let mut theme = fixture(); + theme.roles.push(RoleEntry { + role_key: "uncertain-pulse".to_string(), + outcome: RoleOutcome::GlowIndeterminate(GlowIndeterminateColor { + source_hex: "#4A8FFF".to_string(), + target_dj: 2.3006, + decision_profile: labcolors_core::GlowDecisionProfileV1::StableV1, + site_id: labcolors_core::NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: labcolors_core::NumericalIndeterminacyV1::IntervalOverlap(interval), + constraint_layer: labcolors_core::GlowConstraintLayer::Halo, + }), + }); + + let value: serde_json::Value = + serde_json::from_str(&resolved_json(&theme).unwrap()).unwrap(); + let uncertain = &value["roles"]["uncertain-pulse"]; + assert_eq!(uncertain["reason"], "interval-overlap"); + assert_eq!(uncertain["bounds"]["kind"], "outward"); + assert_eq!(uncertain["bounds"]["lower"].as_f64(), Some(0.9)); + assert_eq!(uncertain["bounds"]["upper"].as_f64(), Some(1.1)); + } + + #[test] + fn numerical_certificate_serializers_keep_canonical_wire_order() { + let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); + let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; + + let mut exact_decision = String::new(); + field_glow_decision_guarantee( + &mut exact_decision, + labcolors_core::DecisionGuaranteeV1::BitExact, + ) + .unwrap(); + assert_eq!( + exact_decision, + ",\"decisionGuarantee\":{\"kind\":\"bit-exact\"}" + ); + + let mut legacy_decision = String::new(); + field_glow_decision_guarantee( + &mut legacy_decision, + labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + ) + .unwrap(); + assert_eq!( + legacy_decision, + ",\"decisionGuarantee\":{\"kind\":\"legacy-platform-dependent-v1\"}" + ); + + let mut unavailable = String::new(); + fields_numerical_indeterminacy( + &mut unavailable, + labcolors_core::NumericalIndeterminacyV1::SoundBoundUnavailable, + ) + .unwrap(); + assert_eq!( + unavailable, + ",\"reason\":\"sound-bound-unavailable\",\"bounds\":{\"kind\":\"unavailable\"}" + ); + + let mut overlap = String::new(); + fields_numerical_indeterminacy( + &mut overlap, + labcolors_core::NumericalIndeterminacyV1::IntervalOverlap(interval), + ) + .unwrap(); + assert_eq!( + overlap, + ",\"reason\":\"interval-overlap\",\"bounds\":{\"kind\":\"outward\",\"lower\":0.9,\"upper\":1.1}" + ); + + let mut bracket = String::new(); + field_material_alpha_guarantee( + &mut bracket, + labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations: 60, + lower_alpha: 0.6374, + upper_alpha: 0.6375, + numerical_profile, + }, + ) + .unwrap(); + assert_eq!( + bracket, + ",\"alphaGuarantee\":{\"kind\":\"bisection-bracket-characterized-v1\",\"iterations\":60,\"lowerAlpha\":0.6374,\"upperAlpha\":0.6375,\"numericalProfile\":\"encoded-srgb-byte-scale-affine-platform-binary64-powf-v1\"}" + ); + + let mut transparent_endpoint = String::new(); + field_material_alpha_guarantee( + &mut transparent_endpoint, + labcolors_core::MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { + numerical_profile, + }, + ) + .unwrap(); + assert_eq!( + transparent_endpoint, + ",\"alphaGuarantee\":{\"kind\":\"transparent-endpoint-characterized-v1\",\"numericalProfile\":\"encoded-srgb-byte-scale-affine-platform-binary64-powf-v1\"}" + ); + + let mut opaque_endpoint = String::new(); + field_material_alpha_guarantee( + &mut opaque_endpoint, + labcolors_core::MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { + numerical_profile, + }, + ) + .unwrap(); + assert_eq!( + opaque_endpoint, + ",\"alphaGuarantee\":{\"kind\":\"opaque-endpoint-characterized-v1\",\"numericalProfile\":\"encoded-srgb-byte-scale-affine-platform-binary64-powf-v1\"}" + ); + } + + #[test] + fn adapter_owns_every_closed_output_vocabulary() { + assert_eq!( + glow_composite_profile_key( + labcolors_core::GlowCompositeProfileV1::EncodedSrgb8ScreenV1, + ) + .unwrap(), + "encoded-srgb8-screen-v1" + ); + assert_eq!( + glow_composite_guarantee_key(labcolors_core::GlowCompositeGuaranteeV1::BitExact) + .unwrap(), + "bit-exact" + ); + assert_eq!( + glow_layer_recipe_profile_key( + labcolors_core::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1, + ) + .unwrap(), + "cam16-jprime-oklab-cusp-v1" + ); + assert_eq!( + glow_diagnostic_profile_key( + labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, + ) + .unwrap(), + "cam16-ucs-jprime-li2017-v1" + ); + assert_eq!( + glow_decision_profile_key(labcolors_core::GlowDecisionProfileV1::StableV1).unwrap(), + "stable-v1" + ); + assert_eq!( + glow_decision_profile_key( + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + ) + .unwrap(), + "legacy-platform-dependent-v1" + ); + assert_eq!( + glow_target_status_key(labcolors_core::GlowTargetStatus::ExactNoopUnreachable).unwrap(), + "exact-noop-unreachable" + ); + assert_eq!( + glow_target_status_key(labcolors_core::GlowTargetStatus::LegacyReached).unwrap(), + "legacy-reached" + ); + assert_eq!( + glow_target_status_key(labcolors_core::GlowTargetStatus::LegacyUnreachable).unwrap(), + "legacy-unreachable" + ); + assert_eq!( + glow_constraint_layer_key(labcolors_core::GlowConstraintLayer::Halo).unwrap(), + "halo" + ); + assert_eq!( + numerical_site_id_key(labcolors_core::NumericalSiteIdV1::GlowTargetOrMaximumV1) + .unwrap(), + "glow-target-or-maximum-v1" + ); + assert_eq!( + material_alpha_status_key(labcolors_core::MaterialAlphaStatusV1::Satisfied).unwrap(), + "satisfied" + ); + assert_eq!( + material_alpha_status_key(labcolors_core::MaterialAlphaStatusV1::Degraded).unwrap(), + "degraded" + ); + assert_eq!( + material_numerical_profile_key( + labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + ) + .unwrap(), + "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" + ); + + let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); + let mut unsupported = String::new(); + let error = field_glow_decision_guarantee( + &mut unsupported, + labcolors_core::DecisionGuaranteeV1::OutwardIntervalV1(interval), + ) + .unwrap_err(); + assert!(matches!( + error, + BindingError::Internal { reason } + if reason.contains("неподдерживаемый Glow DecisionGuaranteeV1") + )); } /// Материал (#89) проецируется в контрактные CSS-переменные: `--lab-` = @@ -347,7 +992,6 @@ mod tests { /// Пин ИМЁН переменных — та поверхность, что потребляет labui-material.css. #[test] fn material_projects_two_layer_css_vars() { - use crate::dto::MaterialColor; let theme = ResolvedTheme { theme: "light", background: "#FFFFFF".to_string(), @@ -357,8 +1001,16 @@ mod tests { tone_hex: "#B4B4BC".to_string(), alpha: 0.6375, worst_contrast: 4.61, + alpha_guarantee: + labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations: 60, + lower_alpha: 0.6374, + upper_alpha: 0.6375, + numerical_profile: + labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1, + }, + alpha_status: labcolors_core::MaterialAlphaStatusV1::Satisfied, floor: 4.5, - guaranteed: true, pole_white: false, achieved_dj: 18.25, tone_compressed: false, @@ -392,6 +1044,26 @@ mod tests { assert_eq!(r["toneHex"], "#B4B4BC"); assert_eq!(r["alpha"].as_f64().unwrap(), 0.6375); assert_eq!(r["worstContrast"].as_f64().unwrap(), 4.61); + assert_eq!( + r["alphaGuarantee"]["kind"], + "bisection-bracket-characterized-v1" + ); + assert_eq!(r["alphaGuarantee"]["iterations"], 60); + assert_eq!(r["alphaGuarantee"]["lowerAlpha"], 0.6374); + assert_eq!(r["alphaGuarantee"]["upperAlpha"], 0.6375); + assert_eq!( + r["alpha"].as_f64().unwrap().to_bits(), + r["alphaGuarantee"]["upperAlpha"] + .as_f64() + .unwrap() + .to_bits(), + "serialized alpha обязан побитно совпадать с проходящей upperAlpha" + ); + assert_eq!( + r["alphaGuarantee"]["numericalProfile"], + "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" + ); + assert_eq!(r["alphaStatus"], "satisfied"); assert_eq!(r["floor"].as_f64().unwrap(), 4.5); assert_eq!(r["guaranteed"], true); assert_eq!(r["poleWhite"], false); @@ -402,6 +1074,143 @@ mod tests { assert_eq!(r["css"].as_str().unwrap(), solid); } + #[test] + fn material_terminal_variants_keep_status_guarantee_and_boolean_correlated() { + let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; + let role = |alpha, alpha_guarantee, alpha_status| { + RoleOutcome::Material(MaterialColor { + tone_hex: "#B4B4BC".to_string(), + alpha, + worst_contrast: 4.61, + alpha_guarantee, + alpha_status, + floor: 4.5, + pole_white: false, + achieved_dj: 18.25, + tone_compressed: false, + hue_vanished: false, + distinct: true, + }) + }; + let theme = ResolvedTheme { + theme: "light", + background: "#FFFFFF".to_string(), + roles: vec![ + RoleEntry { + role_key: "transparent".to_string(), + outcome: role( + 0.0, + labcolors_core::MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { + numerical_profile, + }, + labcolors_core::MaterialAlphaStatusV1::Satisfied, + ), + }, + RoleEntry { + role_key: "opaque".to_string(), + outcome: role( + 1.0, + labcolors_core::MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { + numerical_profile, + }, + labcolors_core::MaterialAlphaStatusV1::Degraded, + ), + }, + ], + }; + + let value: serde_json::Value = + serde_json::from_str(&resolved_json(&theme).unwrap()).unwrap(); + let transparent = &value["roles"]["transparent"]; + assert_eq!(transparent["alpha"], 0.0); + assert_eq!( + transparent["alphaGuarantee"]["kind"], + "transparent-endpoint-characterized-v1" + ); + assert_eq!(transparent["alphaStatus"], "satisfied"); + assert_eq!(transparent["guaranteed"], true); + + let opaque = &value["roles"]["opaque"]; + assert_eq!(opaque["alpha"], 1.0); + assert_eq!( + opaque["alphaGuarantee"]["kind"], + "opaque-endpoint-characterized-v1" + ); + assert_eq!(opaque["alphaStatus"], "degraded"); + assert_eq!(opaque["guaranteed"], false); + } + + #[test] + fn projection_rejects_illegal_output_cross_products() { + let mut glow_theme = fixture(); + let RoleOutcome::Glow(glow) = &mut glow_theme.roles[3].outcome else { + panic!("fixture pulse must be Glow"); + }; + glow.decision_profile = labcolors_core::GlowDecisionProfileV1::StableV1; + let glow_error = resolved_json(&glow_theme).unwrap_err(); + assert!(matches!( + glow_error, + BindingError::Internal { reason } if reason.contains("несогласованный Glow provenance") + )); + + let indeterminate_theme = ResolvedTheme { + theme: "light", + background: "#FFFFFF".to_string(), + roles: vec![RoleEntry { + role_key: "indeterminate".to_string(), + outcome: RoleOutcome::GlowIndeterminate(GlowIndeterminateColor { + source_hex: "#4A8FFF".to_string(), + target_dj: 2.3006, + decision_profile: + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + site_id: labcolors_core::NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: labcolors_core::NumericalIndeterminacyV1::SoundBoundUnavailable, + constraint_layer: labcolors_core::GlowConstraintLayer::Halo, + }), + }], + }; + let indeterminate_error = resolved_json(&indeterminate_theme).unwrap_err(); + assert!(matches!( + indeterminate_error, + BindingError::Internal { reason } + if reason.contains("несогласованный GlowIndeterminate provenance") + )); + + let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; + let material_theme = ResolvedTheme { + theme: "light", + background: "#FFFFFF".to_string(), + roles: vec![RoleEntry { + role_key: "material".to_string(), + outcome: RoleOutcome::Material(MaterialColor { + tone_hex: "#B4B4BC".to_string(), + alpha: 0.5, + worst_contrast: 4.61, + alpha_guarantee: + labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { + iterations: 60, + lower_alpha: 0.49, + upper_alpha: 0.75, + numerical_profile, + }, + alpha_status: labcolors_core::MaterialAlphaStatusV1::Satisfied, + floor: 4.5, + pole_white: false, + achieved_dj: 18.25, + tone_compressed: false, + hue_vanished: false, + distinct: true, + }), + }], + }; + let material_error = resolved_json(&material_theme).unwrap_err(); + assert!(matches!( + material_error, + BindingError::Internal { reason } + if reason.contains("несогласованный Material provenance") + )); + } + /// Числа переживают декаду через кратчайшую десятичную запись: биты double /// после парсинга равны исходным (и JSON синтаксически валиден для serde). #[test] diff --git a/crates/labcolors-wasm/tests/data/labui.config.json b/crates/labcolors-wasm/tests/data/labui.config.json index 24571e96..3bc47dbc 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.json +++ b/crates/labcolors-wasm/tests/data/labui.config.json @@ -1018,7 +1018,8 @@ "source": { "kind": "brand" }, - "step": "base" + "step": "base", + "decision_profile": "legacy-platform-dependent-v1" } }, { @@ -1029,7 +1030,8 @@ "kind": "sentiment", "name": "danger" }, - "step": "base" + "step": "base", + "decision_profile": "legacy-platform-dependent-v1" } }, { @@ -1040,7 +1042,8 @@ "kind": "sentiment", "name": "warning" }, - "step": "base" + "step": "base", + "decision_profile": "legacy-platform-dependent-v1" } }, { @@ -1051,7 +1054,8 @@ "kind": "neutral", "pick": "light" }, - "step": "base" + "step": "base", + "decision_profile": "legacy-platform-dependent-v1" } }, { diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 55b07a5e..a3c8e669 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -1,5 +1,5 @@ -//! Headless-browser parity smoke: the binding's resolve must equal the native -//! `resolve_named_set` it wraps, role for role. +//! Headless-browser contract smoke: JS-binding resolve must equal the core +//! `resolve_named_set` it wraps, role for role, внутри одного wasm runtime. //! //! Run with `wasm-pack test --headless --chrome` (D1 default from the chapter). //! The engine is agnostic (ADR-0001 PR-c): it has no built-in table, so parity @@ -10,6 +10,10 @@ #![cfg(target_arch = "wasm32")] +use labcolors_conformance::{ + AlphaVector, ContrastVector, DRIFT_TOL, LadderVector, Manifest, MuddinessVector, Pack, + SolveOutcome, SolveVector, +}; use labcolors_core::config::ThemeConfig; use labcolors_core::semantic::NamedRoleTable; use labcolors_core::{BgInput, Resolved, ViewingConditions, resolve_named_set}; @@ -21,7 +25,7 @@ use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); /// The frozen labui passport — the config the built-in default table used to -/// hardcode. Both the boundary (via `loadConfig`) and the native expectation +/// hardcode. Both the boundary (via `loadConfig`) and the core expectation /// (via `ConfigDto` → `ThemeConfig` → `compile_named_role_table`) read this one /// SSOT, so the two sides cannot diverge on their input. /// @@ -40,18 +44,18 @@ const LABUI_JSON: &str = include_str!("data/labui.config.json"); /// real consumer uses must hold parity. Refresh on passport changes. const LABUI_PROD_JSON: &str = include_str!("data/labui.config.prod.json"); -/// Build the native role table for a passport through the same public path +/// Build the core role table for a passport through the same public path /// `loadConfig` uses, so the parity oracle is the core's own compile, not a /// parallel copy. -fn native_table(passport: &str) -> NamedRoleTable { +fn core_table(passport: &str) -> NamedRoleTable { let dto: ConfigDto = serde_json::from_str(passport).expect("passport parses"); let cfg = ThemeConfig::try_from(dto).expect("DTO → ThemeConfig"); cfg.compile_named_role_table().expect("passport compiles") } -/// Build the native labui role table (canonical fixture). -fn native_labui_table() -> NamedRoleTable { - native_table(LABUI_JSON) +/// Build the core labui role table (canonical fixture). +fn core_labui_table() -> NamedRoleTable { + core_table(LABUI_JSON) } /// A boundary engine with the given passport loaded. @@ -78,6 +82,18 @@ fn get_obj(obj: &JsValue, key: &str) -> JsValue { js_sys::Reflect::get(obj, &JsValue::from_str(key)).expect("property present") } +fn get_num(obj: &JsValue, key: &str) -> f64 { + get_obj(obj, key) + .as_f64() + .unwrap_or_else(|| panic!("{key} must be a number")) +} + +fn get_bool(obj: &JsValue, key: &str) -> bool { + get_obj(obj, key) + .as_bool() + .unwrap_or_else(|| panic!("{key} must be a boolean")) +} + /// Read the `message` of a rejected `JsError`. A `JsError` crosses as a JS /// `Error` object, so the human text (carrying our stable code) is its /// `.message` property, not the value's own string form. @@ -88,10 +104,158 @@ fn error_message(err: wasm_bindgen::JsError) -> String { get_str(&value, "message").expect("JsError must carry a .message property") } +fn approx_pack_number(actual: f64, expected: f64, context: &str) { + assert!( + (actual - expected).abs() <= DRIFT_TOL, + "{context}: {actual} != {expected} within {DRIFT_TOL}" + ); +} + +fn assert_hex_within_one(actual: &str, expected: &str, context: &str) { + let channels = |hex: &str| { + [1, 3, 5].map(|offset| u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap()) + }; + for (actual, expected) in channels(actual).into_iter().zip(channels(expected)) { + assert!( + actual.abs_diff(expected) <= 1, + "{context}: {actual} differs from {expected} by more than one LSB" + ); + } +} + +/// The committed 82-vector pack is replayed inside the actual wasm32 runtime. +/// Same-runtime core/boundary parity alone cannot detect a platform-specific +/// libm drift, so this anchors wasm32 independently to committed bytes/values. +#[wasm_bindgen_test] +fn committed_conformance_pack_replays_in_wasm32() { + let fresh = Pack::generate().expect("canonical pack generation"); + let contrasts: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/contrasts.json")).unwrap(); + assert_eq!(contrasts.len(), fresh.contrasts.len()); + for (committed, actual) in contrasts.iter().zip(&fresh.contrasts) { + assert_eq!( + (&committed.fg, &committed.bg, &committed.theme), + (&actual.fg, &actual.bg, &actual.theme) + ); + approx_pack_number(actual.lc, committed.lc, "contrast lc"); + approx_pack_number(actual.wcag_ratio, committed.wcag_ratio, "contrast wcag"); + } + + let ladders: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/ladders.json")).unwrap(); + assert_eq!(ladders.len(), fresh.ladders.len()); + for (committed, actual) in ladders.iter().zip(&fresh.ladders) { + assert_eq!(committed.position, actual.position); + approx_pack_number(actual.alpha_light, committed.alpha_light, "ladder light"); + approx_pack_number(actual.alpha_dark, committed.alpha_dark, "ladder dark"); + } + + let alpha: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/alpha.json")).unwrap(); + assert_eq!(alpha.len(), fresh.alpha.len()); + for (committed, actual) in alpha.iter().zip(&fresh.alpha) { + assert_eq!((&committed.tint, &committed.bg), (&actual.tint, &actual.bg)); + approx_pack_number(actual.alpha, committed.alpha, "alpha input"); + assert_eq!(actual.composite, committed.composite); + approx_pack_number(actual.min_alpha, committed.min_alpha, "alpha minimum"); + } + + let solve: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/solve.json")).unwrap(); + assert_eq!(solve.len(), fresh.solve.len()); + for (committed, actual) in solve.iter().zip(&fresh.solve) { + assert_eq!( + (&committed.bg, committed.contract, &committed.theme), + (&actual.bg, actual.contract, &actual.theme) + ); + match (&committed.outcome, &actual.outcome) { + ( + SolveOutcome::Solved { + hex: committed_hex, + lc: committed_lc, + wcag_ratio: committed_wcag, + floor_override: committed_floor, + }, + SolveOutcome::Solved { + hex: actual_hex, + lc: actual_lc, + wcag_ratio: actual_wcag, + floor_override: actual_floor, + }, + ) => { + assert_hex_within_one(actual_hex, committed_hex, "solve hex"); + approx_pack_number(*actual_lc, *committed_lc, "solve lc"); + approx_pack_number(*actual_wcag, *committed_wcag, "solve wcag"); + assert_eq!(actual_floor, committed_floor); + } + ( + SolveOutcome::Unreachable { + code: committed_code, + }, + SolveOutcome::Unreachable { code: actual_code }, + ) => assert_eq!(actual_code, committed_code), + pair => panic!("solve outcome class drift: {pair:?}"), + } + } + + let muddiness: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/muddiness.json")).unwrap(); + assert_eq!(muddiness.len(), fresh.muddiness.len()); + for (committed, actual) in muddiness.iter().zip(&fresh.muddiness) { + assert_eq!(actual.hex, committed.hex); + approx_pack_number(actual.score, committed.score, "muddiness"); + } + + let committed_manifest: Manifest = + serde_json::from_str(include_str!("../../../conformance/vectors/manifest.json")).unwrap(); + let fresh_manifest = fresh.manifest(); + assert_eq!(committed_manifest.pack_version, fresh_manifest.pack_version); + assert_eq!(committed_manifest.core_version, fresh_manifest.core_version); + assert_eq!(committed_manifest.counts, fresh_manifest.counts); + assert_eq!( + committed_manifest.numerical_sites, + fresh_manifest.numerical_sites + ); + assert_eq!(committed_manifest.counts.total, 82, "anti-vacuum pack size"); +} + +/// The public WASM compatibility proxy is wired to the same frozen vectors as +/// every other delivery surface. The committed file is the oracle: this test +/// intentionally carries no second hand-written semantic threshold or score. +#[wasm_bindgen_test] +fn public_muddiness_binding_matches_committed_conformance_vectors() { + let committed: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/muddiness.json")).unwrap(); + let manifest: Manifest = + serde_json::from_str(include_str!("../../../conformance/vectors/manifest.json")).unwrap(); + assert!( + manifest.counts.muddiness > 0, + "compatibility corpus must be non-vacuous" + ); + assert_eq!(committed.len(), manifest.counts.muddiness); + + let colors = LabColors::new(); + for vector in committed { + let actual = colors + .muddiness(&vector.hex) + .unwrap_or_else(|error| panic!("{} must remain accepted: {error:?}", vector.hex)); + approx_pack_number( + actual, + vector.score, + &format!("public muddiness {}", vector.hex), + ); + } + + assert!( + colors.muddiness("not_a_hex").is_err(), + "invalid public input must reject rather than panic or fabricate a score" + ); +} + /// Shared parity assertion: for a passport, the binding's `resolveTheme` -/// must reproduce the native `resolve_named_set`, role for role. Expectations +/// must reproduce the core `resolve_named_set`, role for role. Expectations /// come straight from the core inside the same wasm runtime — never hand-typed. -/// The native side derives its ViewingConditions from the SAME core enum the +/// The core side derives its ViewingConditions from the SAME enum the /// boundary resolves through (`Theme::viewing_conditions()`): a hardcoded /// `srgb()` here silently diverges on any non-srgb theme (dark = dim surround) /// — exactly the miss that kept the old light-only test blind to dim parity. @@ -110,9 +274,9 @@ fn theme_vc(theme: &str) -> ViewingConditions { fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { let bg = BgInput::solid(bg_hex).expect("bg is valid"); - let table = native_table(passport); + let table = core_table(passport); let vc = theme_vc(theme); - let native = resolve_named_set(&bg, &table, &vc); + let core_resolved = resolve_named_set(&bg, &table, &vc); // The binding result for the same inputs, from a loaded config. let engine = boundary_with(passport); @@ -128,7 +292,7 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { "theme echoed back" ); - for (name, resolved) in &native { + for (name, resolved) in &core_resolved { let entry = get_obj(&roles, name); let kind = get_str(&entry, "kind").expect("every role has a kind"); match resolved { @@ -137,7 +301,7 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { assert_eq!( get_str(&entry, "hex").as_deref(), Some(solved.hex()), - "{name} hex must match native" + "{name} hex must match core" ); } Resolved::None => { @@ -151,7 +315,7 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { assert_eq!( get_str(&entry, "tintHex").as_deref(), Some(r.tint_hex()), - "{name} tint must match native" + "{name} tint must match core" ); } Resolved::Glow(g) => { @@ -159,7 +323,94 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { assert_eq!( get_str(&entry, "coreHex").as_deref(), Some(g.core_hex()), - "{name} glow core must match native" + "{name} glow core must match core" + ); + assert_eq!( + get_str(&entry, "haloHex").as_deref(), + Some(g.halo_hex()), + "{name} glow halo must match core" + ); + assert_eq!(get_num(&entry, "alpha").to_bits(), g.alpha().to_bits()); + assert_eq!(get_str(&entry, "alphaCss").as_deref(), Some(g.alpha_css())); + assert_eq!( + get_str(&entry, "compositeProfile").as_deref(), + Some(g.composite_profile().key()) + ); + assert_eq!( + get_str(&entry, "compositeGuarantee").as_deref(), + Some(g.halo_composite_certificate().guarantee().key()) + ); + assert_eq!( + get_str(&entry, "layerRecipeProfile").as_deref(), + Some(g.layer_recipe_profile().key()) + ); + assert_eq!( + get_str(&entry, "appearanceDiagnosticProfile").as_deref(), + Some(g.appearance_diagnostic_profile().key()) + ); + assert_eq!( + get_str(&entry, "selectionDiagnosticProfile").as_deref(), + g.selection_diagnostic_profile() + .map(|profile| profile.key()) + ); + assert_eq!( + get_str(&entry, "decisionProfile").as_deref(), + Some(g.decision_profile().key()) + ); + let decision_guarantee = get_obj(&entry, "decisionGuarantee"); + assert_eq!( + get_str(&decision_guarantee, "kind").as_deref(), + Some(g.decision_guarantee().key()) + ); + if let Some(interval) = g.decision_guarantee().interval() { + assert_eq!( + get_num(&decision_guarantee, "lower").to_bits(), + interval.lower().to_bits() + ); + assert_eq!( + get_num(&decision_guarantee, "upper").to_bits(), + interval.upper().to_bits() + ); + } + assert_eq!( + get_str(&entry, "constraintLayer").as_deref(), + Some(g.constraint_layer().key()) + ); + assert_eq!( + get_num(&entry, "targetDj").to_bits(), + g.target_dj().to_bits() + ); + assert_eq!( + get_str(&entry, "targetStatus").as_deref(), + Some(g.target_status().key()) + ); + assert_eq!( + get_str(&entry, "haloCompositeHex").as_deref(), + Some(g.halo_composite_hex()) + ); + assert_eq!( + get_num(&entry, "haloAchievedDj").to_bits(), + g.halo_achieved_dj().to_bits() + ); + assert_eq!( + get_str(&entry, "coreCompositeHex").as_deref(), + Some(g.core_composite_hex()) + ); + assert_eq!( + get_num(&entry, "coreAchievedDj").to_bits(), + g.core_achieved_dj().to_bits() + ); + assert_eq!( + get_num(&entry, "achievedDj").to_bits(), + g.halo_achieved_dj().to_bits() + ); + assert_eq!( + get_bool(&entry, "degraded"), + matches!( + g.target_status(), + labcolors_core::GlowTargetStatus::ExactNoopUnreachable + | labcolors_core::GlowTargetStatus::LegacyUnreachable + ) ); } // `Resolved` is `#[non_exhaustive]`: a future core variant must be @@ -171,7 +422,7 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { /// Canonical labui passport (target M1 text-anchor style), white/light. #[wasm_bindgen_test] -fn resolve_theme_matches_native_named_resolve() { +fn resolve_theme_matches_core_named_resolve() { assert_parity(LABUI_JSON, "#FFFFFF", "light"); } @@ -179,7 +430,7 @@ fn resolve_theme_matches_native_named_resolve() { /// path labui takes today must hold parity on both of its theme anchors. /// Guards the class: "a recipe style a real consumer uses is untested in wasm". #[wasm_bindgen_test] -fn resolve_theme_matches_native_on_prod_passport() { +fn resolve_theme_matches_core_on_prod_passport() { assert_parity(LABUI_PROD_JSON, "#FFFFFF", "light"); assert_parity(LABUI_PROD_JSON, "#101012", "dark"); } @@ -227,19 +478,19 @@ fn vars_mirror_reachable_roles_in_oklch() { } /// `recheckContrast` across the wasm boundary: the returned `Float64Array` -/// reproduces the native `resolve_named_set`'s own `(lc, wcag)` per role, accepts +/// reproduces the core `resolve_named_set`'s own `(lc, wcag)` per role, accepts /// the same shorthand hex forms as `resolveTheme`, and rejects a bad foreground. #[wasm_bindgen_test] fn recheck_contrast_boundary_matches_resolve_and_shares_hex_contract() { let bg = "#FFFFFF"; - let native = resolve_named_set( + let core_resolved = resolve_named_set( &BgInput::solid(bg).expect("white is valid"), - &native_labui_table(), + &core_labui_table(), &ViewingConditions::srgb(), ); let mut fgs: Vec = Vec::new(); let mut want: Vec<(f64, f64)> = Vec::new(); - for (_name, resolved) in &native { + for (_name, resolved) in &core_resolved { if let Resolved::Color { solved, .. } = resolved { fgs.push(solved.hex().to_string()); want.push((solved.lc(), solved.wcag_ratio())); @@ -290,6 +541,104 @@ fn recheck_contrast_boundary_matches_resolve_and_shares_hex_contract() { ); } +#[wasm_bindgen_test] +fn stable_glow_exact_noop_recheck_crosses_the_wasm_boundary() { + let engine = LabColors::new(); + assert!( + engine + .is_stable_glow_point_noop("#010000", "#FE0000") + .expect("sub-LSB endpoint is exact") + ); + assert!( + engine + .is_stable_glow_point_noop("#001", "fff") + .expect("#RGB and bare spelling share resolveTheme normalization") + ); + assert!( + !engine + .is_stable_glow_point_noop("#800000", "#FE0000") + .expect("first crossing endpoint is non-noop") + ); + + let tint_error = engine + .is_stable_glow_point_noop("xyz", "#FFFFFF") + .expect_err("invalid tint rejects"); + assert!(error_message(tint_error).contains("invalid_color")); + let background_error = engine + .is_stable_glow_point_noop("#000000", "bad-background") + .expect_err("invalid background rejects"); + assert!(error_message(background_error).contains("invalid_background")); +} + +#[wasm_bindgen_test] +fn stable_glow_both_lawful_outcomes_cross_the_wasm_boundary() { + let stable_json = LABUI_JSON.replacen("legacy-platform-dependent-v1", "stable-v1", 1); + let engine = boundary_with(&stable_json); + + let white: JsValue = engine + .resolve_theme("#FFFFFF", "light") + .expect("exact no-op resolves") + .into(); + let white_roles = get_obj(&white, "roles"); + let white_vars = get_obj(&white, "vars"); + let glow = get_obj(&white_roles, "fx-glow-brand"); + assert_eq!(get_str(&glow, "kind").as_deref(), Some("glow")); + assert_eq!( + get_str(&glow, "decisionProfile").as_deref(), + Some("stable-v1") + ); + assert_eq!( + get_str(&glow, "layerRecipeProfile").as_deref(), + Some("cam16-jprime-oklab-cusp-v1") + ); + assert_eq!( + get_str(&glow, "appearanceDiagnosticProfile").as_deref(), + Some("cam16-ucs-jprime-li2017-v1"), + "full resolved Glow must identify its core appearance measurement" + ); + assert_eq!( + get_str(&glow, "selectionDiagnosticProfile"), + None, + "exact no-op must not claim a selection diagnostic" + ); + assert_eq!( + get_str(&glow, "targetStatus").as_deref(), + Some("exact-noop-unreachable") + ); + let guarantee = get_obj(&glow, "decisionGuarantee"); + assert_eq!(get_str(&guarantee, "kind").as_deref(), Some("bit-exact")); + for key in [ + "--lab-fx-glow-brand", + "--lab-fx-glow-brand-core", + "--lab-fx-glow-brand-alpha", + ] { + assert!(get_str(&white_vars, key).is_some(), "missing {key}"); + } + + let dark: JsValue = engine + .resolve_theme("#101012", "dark") + .expect("nontrivial stable point is a terminal outcome") + .into(); + let dark_roles = get_obj(&dark, "roles"); + let dark_vars = get_obj(&dark, "vars"); + let indeterminate = get_obj(&dark_roles, "fx-glow-brand"); + assert_eq!( + get_str(&indeterminate, "kind").as_deref(), + Some("glow-indeterminate") + ); + assert_eq!( + get_str(&indeterminate, "reason").as_deref(), + Some("sound-bound-unavailable") + ); + for key in [ + "--lab-fx-glow-brand", + "--lab-fx-glow-brand-core", + "--lab-fx-glow-brand-alpha", + ] { + assert!(get_str(&dark_vars, key).is_none(), "unsafe {key}"); + } +} + /// `resolveTheme` before any `loadConfig` rejects with the stable /// `config_required` code — the agnostic contract, surfaced across the boundary. #[wasm_bindgen_test] diff --git a/docs/NAMING.md b/docs/NAMING.md index 620fa695..b640378d 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -15,9 +15,9 @@ | --- | --- | | членов workspace (Cargo.toml `members`, глоб развёрнут по ФС) | 6 | | крейтов семейства в crates/ | 5 | -| экспорт-субпутей package.json @labpics/colors | 7 | +| экспорт-субпутей package.json @labpics/colors | 8 | | python-скриптов scripts/*.py | 2 | -| маркдаун-доков docs/**/*.md (включая этот канон) | 10 | +| маркдаун-доков docs/**/*.md (включая этот канон) | 12 | | векторов conformance/vectors/*.json (включая manifest) | 6 | | файлов вне закона имён | 4 | @@ -55,6 +55,8 @@ `./adapt-theme`, `./effective-bg`. Каждый субпуть разрешается в исходник packages/colors (exports без кода запрещены — сверяет typecheck пакета). - Служебный субпуть `./package.json` — стандарт npm, разрешён законом. +- Служебный субпуть `./build-metadata.json` — versioned machine-readable build + metadata опубликованных байтов; расширение фиксирует JSON-формат контракта. - Артефактный субпуть `./pkg/labcolors_bg.wasm` — см. «Известные отступления». - Файлы пакета — kebab-case (`apply-theme.js` + `apply-theme.d.ts`). @@ -71,6 +73,8 @@ `verification-map.md`); канонам разрешён КАПС-стем (`NAMING.md`). - ADR в docs/decisions/ — `NNNN-kebab-тема.md` (`0001-config-boundary.md`); номер сквозной, тема — kebab. +- Миграции в docs/migrations/ — kebab-case по предмету breaking-контракта + (`exact-alpha-glow.md`); одна дока обязана покрывать upgrade и rollback. - Векторы конформанса — conformance/vectors/`<домен>.json`, домен — одно kebab-слово (alpha, contrasts, ladders, muddiness, solve) + manifest.json. @@ -87,6 +91,9 @@ - `./pkg/labcolors_bg.wasm` — субпуть-артефакт wasm-pack: имя `labcolors_bg.wasm` генерирует wasm-bindgen из `--out-name labcolors`, snake_case продиктован тулингом и руками не переименовывается. +- `./build-metadata.json` — служебный JSON-контракт build metadata; расширение + намеренно остаётся частью subpath, чтобы формат был явным и потребитель не + принимал его за исполняемый JS entrypoint. - `crates/labcolors-core/tests/data/labui_emission_golden.txt` — golden-фикстура Rust-теста, snake_case в тон соседним `.rs` (данные теста наследуют закон своего потребителя). diff --git a/docs/conformance/local-swift-attestation.md b/docs/conformance/local-swift-attestation.md index 201aa37b..d12dd553 100644 --- a/docs/conformance/local-swift-attestation.md +++ b/docs/conformance/local-swift-attestation.md @@ -1,10 +1,19 @@ -# Аттестация локального прогона Swift-conformance (#84) - -Платные GitHub-hosted macOS-раннеры исключены владельцем навсегда, поэтому -Swift-биндинг валидирован **локально** в официальном swift-контейнере (Linux -x86_64). Тот же UniFFI-биндинг, тот же conformance-пак, ядро под -`x86_64-unknown-linux-gnu`. Кросс-платформа держится толерантностью -`DRIFT_TOL = 1e-6` (см. `conformance/README.md`). +# Исторический Linux x86_64 прогон Swift/UniFFI pack 1.0.0 (#84) + +> Статус: **SUPERSEDED для текущего релиза**. Этот зафиксированный прогон +> аттестовал `packVersion=1.0.0` / `coreVersion=0.1.0`. Breaking-релиз +> переводит контракт на `packVersion=2.0.0` / `coreVersion=0.2.0`, поэтому +> результаты ниже остаются воспроизводимым историческим свидетельством, но не +> доказательством актуального пака. Новая аттестация обязана дополнительно +> воспроизвести 7 alpha-векторов, включая точный half-tie +> `#C0B2FA @ 0.122` над `#000000` → `#17161F`. + +Текущая runner-политика исключает платные GitHub-hosted macOS-раннеры, поэтому +этот исторический Swift-биндинг был проверен **локально** в официальном +swift-контейнере на Linux x86_64. Тот же UniFFI-биндинг вызвал ядро под +`x86_64-unknown-linux-gnu`. `DRIFT_TOL = 1e-6` задаёт правило сравнения пака +(SSOT — `crates/labcolors-conformance/src/lib.rs`), но сам по себе не доказывает +поведение на другой ОС или архитектуре. Прогон воспроизводим: `bindings/swift/ci/run-conformance.sh` — единый источник для этого прогона и для self-hosted CI-джобы (`.github/workflows/native-conformance.yml`). @@ -13,7 +22,8 @@ x86_64). Тот же UniFFI-биндинг, тот же conformance-пак, яд | | | |---|---| -| Дата | 2026-07-08 | +| Дата исторического прогона | 2026-07-08 | +| Аттестованный контракт | conformance pack `1.0.0`, core `0.1.0` | | Ядро/контейнер | `swift:6.1` (Docker Desktop, Linux x86_64 / WSL2) | | Swift | `6.1.3 (swift-6.1.3-RELEASE)`, target `x86_64-unknown-linux-gnu` | | Rust | `rustc 1.96.1 (31fca3adb 2026-06-26)`, `cargo 1.96.1` | @@ -36,8 +46,9 @@ uniffi-bindgen сгенерировал биндинги из `liblabcolors.so` ## Rust-раннер-референс (в том же контейнере, Linux x86_64) -Ядро на Linux воспроизвело закоммиченный пак (сгенерирован на Windows) в -пределах толерантности — кросс-платформенность на реальной второй платформе: +Ядро на Linux воспроизвело закоммиченный пак (сгенерированный на Windows) в +пределах толерантности. Это свидетельство именно для показанного Linux x86_64 +runtime; оно не является Apple/macOS-аттестацией: ``` test result: ok. 4 passed; 0 failed; (labcolors-conformance lib) @@ -70,11 +81,10 @@ Test Suite 'All tests' passed `testLadders` (25), `testAlpha` (6, композит-hex точно), `testSolve` (6, вкл. честный `unreachable`), `testMuddiness` (4), `testCoreVersionMatchesManifest`. -## Что это доказывает +## Что доказал этот исторический прогон -Рантайм-ядро Rust, вызванное со Swift-стороны через UniFFI на нативной -платформе, воспроизводит канон в пределах conformance-толерантности — **ядро -динамическое, не запечённое**. macOS/arm64 не покрыт (платные раннеры -исключены); линейка транзисторных различий libm между Linux-x86_64 и -macOS-arm64 поглощается той же `DRIFT_TOL`, что уже подтверждена на трёх -x86_64-точках (Windows-генерация → Linux-ферма ci.yml → Linux-контейнер здесь). +Рантайм-ядро Rust `0.1.0`, вызванное со Swift-стороны через UniFFI на Linux +x86_64, воспроизвело pack `1.0.0` в пределах conformance-толерантности — **ядро +было динамическим, не запечённым**. Этот результат не переносится автоматически +ни на pack `2.0.0`, ни на macOS/arm64, ни на iOS. Для них нужен отдельный +исполняемый прогон; допуск `DRIFT_TOL` не заменяет такое evidence. diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index b1771b89..c51e78d5 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -63,11 +63,13 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch - `themes` — словарь `имя → пресет условий просмотра (VC)` из закрытого физического меню (`srgb | dim | srgb-ic | dim-ic`, kebab-контракт JSON); произвольных VC-чисел в конфиге нет. -- `roles` — имя → рецепт из физического меню (6 вариантов; `kind` в JSON — kebab): - `text-anchor(fraction, floor)` · `dj-anchor(light, dark)` · +- `roles` — имя → рецепт из физического меню (10 вариантов; `kind` в JSON — kebab): + `text-anchor(fraction, floor, hue?)` · `dj-anchor(light, dark)` · `decorative-lc(magnitude)` (Lc-величина, стек теней) · - `ladder(brand|family|sentiment|neutral, position)` · - `alpha-analog(of, alpha)` (композит-инверсия, alpha.rs) · `zero`. + `ladder(source, position, floor?)` · `glow(source, step, decision_profile)` · + `pair-fill(source)` · `pair-label(source, fraction, floor)` · + `alpha-analog(of, alpha)` (композит-инверсия, alpha.rs) · + `material(source, tone_light, tone_dark, floor)` · `zero`. `position` — ЗАКРЫТОЕ меню позиций лестницы (label / fill P–Q / border / focus …); перечень зафиксирован приложением A. Рецепт `ladder` реализован (`RoleSpec::Ladder`, `crate::ladder`) и поглотил акцентный GAP #59; как и @@ -75,9 +77,13 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch `DjMagnitude`, `alpha.rs`). - `aliases` — компонентные алиасы на существующие роли. -Имена ролей — произвольные строки (`[a-z0-9-]+`), эмиссия `--lab-{имя}` -существующей механикой. Коллапс-контракт исполняется схемой: в конфиге labui -нет `static-*/inverted-*/on-*/mat-*` — роли решаются от фактического фона. +Имена ролей — произвольные строки (`[a-z0-9-]+`). Каждая роль и каждый алиас +резервируют client-owned primary `--lab-{имя}` ещё до resolve; `Zero` сохраняет +это имя в metadata, хотя значения не эмитит. Многоключевые outcomes дополнительно +резервируют свой shape: Glow — `-core/-alpha`, Material — `-01/-02`. Поэтому +порядок JSON-писателей или фон не могут решить коллизию молча. Коллапс-контракт +исполняется схемой: в конфиге labui нет `static-*/inverted-*/on-*/mat-*` — роли +решаются от фактического фона. ## API diff --git a/docs/decisions/0004-finite-alpha-glow-reference.md b/docs/decisions/0004-finite-alpha-glow-reference.md new file mode 100644 index 00000000..2afac565 --- /dev/null +++ b/docs/decisions/0004-finite-alpha-glow-reference.md @@ -0,0 +1,226 @@ +# ADR-0004: конечный sRGB8-контракт alpha и point-glow + +- Статус: принято +- Дата: 2026-07-10 +- Дополнение numerical-decision boundary: 2026-07-11 +- Связанные задачи: #41, #218, #221, #223, #233, #241, #258, #259, #281, + #282 + +## Контекст + +Прежний glow-солвер выполнял фиксированные 48 шагов бисекции. Это требовало +неподтверждённой монотонности CAM16-UCS J′ по alpha. Затем числовая alpha +округлялась до четырёх знаков независимо от уже выбранного sRGB8-композита. +На воспроизводимом входе `halo=#4A8FFF`, `bg=#101012`, `target=2.3006` +граница `1275/35372` превращалась в `0.0360`, возвращала предыдущий байт +`#12151B` и нарушала собственную цель. + +У straight-alpha был отдельный дефект той же природы. Нормализованный путь +`(byte/255) · alpha · 255` менял точную половину из-за binary64-округления: +для `#C0B2FA @ 0.122` над чёрным канал `250 · 0.122 = 30.5` обязан дать 31 в +объявленном byte-reference, но старый путь давал 30. Отчётные +`compositeHex`, Lc и WCAG могли относиться к соседнему байту. + +## Решение + +Непрерывная алгебра опирается на W3C Compositing and Blending Level 1: +[source-over §9.1.4](https://www.w3.org/TR/compositing-1/#porterduffcompositingoperators_srcover) +и [screen §10.1.3](https://www.w3.org/TR/compositing-1/#blending-screen). +Спецификация задаёт операторы, но не нашу политику финального sRGB8-round; +выбор encoded sRGB как blending space и байтовая сетка ниже явно являются +версионированным reference-профилем продукта, а не приписываются W3C или +неизвестному browser/output pipeline. + +### 1. Два численных домена не смешиваются + +Непрерывная алгебра encoded-sRGB `[0,1]` остаётся для обратного хода alpha и +материальных интервальных расчётов. Финальный эмитируемый композит имеет +отдельный домен `encoded-sRGB8`: + +```text +source-over: round(bg_byte + alpha · (tint_byte − bg_byte)) +screen: round(bg_byte + alpha · tint_byte · (255 − bg_byte) / 255) +``` + +Source-over affine-форма алгебраически равна оператору W3C, но в binary64 +монотонна по alpha. Expanded-запись с двумя произведениями на известных ULP- +швах давала PASS→FAIL→PASS и делала lower-bound ложным. Порядок операций +является частью reference-контракта. Он не выдаётся за +гарантию неизвестного renderer или color-management pipeline; такую +применимость проверяют #233/#241. + +Эмиссионный alpha-аналог решается прямо на этой конечной сетке. Сначала +проверяется запрошенная alpha: если существует хотя бы один byte-тинт, она не +меняется. Иначе полный lower-bound по упорядоченным битам `f64` находит первый +`binary64`, который проходит тот же reference-композитор; непосредственный +предшественник обязан не проходить. Каналы независимы, допустимый интервал +тинтов находится монотонным поиском, а канонический тинт минимизирует ошибку до +непрерывной инверсии (при равенстве выбирается меньший байт). Поэтому +`alphaCoerced` означает реальную недостижимость эмитируемой пары, а не более +строгой вспомогательной continuous-модели. + +Публичные прямые compositor/hex/CSS/glow-границы отвергают NaN, бесконечности и +выход из домена через `Result`. Legacy continuous API +`invert_composite_encoded` / `min_alpha_encoded` / `resolve_alpha_analog` +сохраняют прежний `Option`-контракт до отдельной миграции #41. Различная +семантика debug/release запрещена. + +Эмиссионная hex-обёртка `resolve_alpha_analog_hex` сильнее continuous API: для +валидных hex и `requested_alpha ∈ [0,1]` ответ существует всегда, поэтому её +тип — `Result<(String, f64), String>` без внутреннего `Option`. Некорректная +alpha не клампится в другой запрос, а возвращает `Err`. + +### 2. Glow перечисляет actual binary64 states, а semantic decision профилирован + +Для каждого канала production-перечислитель находит первый representable +binary64-alpha, переводящий публичный reference-композитор в следующий байт. +Поиск — точный lower-bound по упорядоченным положительным `f64`-битам с вызовом +того же `bg + alpha · glow · (255−bg) / 255`, который выполняет официальный +потребитель. Аналитическая рациональная half-wall используется только как +ускоряющий seed: экспоненциальный поиск строит bracket между уже известными +failing/passing endpoints, затем bitwise lower-bound находит точную границу; +корректность от качества seed не зависит. Канальные переходы сливаются только +тогда, когда совпадают их фактические first-passing bits. Это даёт полное +множество достижимых в данном численном профиле RGB-состояний и bit-exact +certificate выбранного +point-композита. Однако порядок этих states по диагностическому CAM16-UCS J′ — +другая задача: она содержит `powf`/libm и влияет на semantic branch. + +Ранее рассмотренная рациональная группировка стенок отвергнута для production: +алгебраически равные дроби при разных факторизациях выражения могут разойтись на +несколько ULP. Контрпример `glow=#010200`, `background=#018000` создаёт +достижимый промежуточный state `#018100`, который рациональная группировка +теряла. Поэтому точное для рациональной модели число 763 не является границей +фактического runtime-потока. + +Доказанная безопасная верхняя граница — **не более 766 состояний**: каждый +переход увеличивает хотя бы один из трёх байтов, каждый байт может увеличиться +не более 255 раз, плюс существует начальное состояние. Tightness этой границы +не установлена и не требуется контракту. Рабочий перечислитель хранит текущий +state и по одной следующей границе на канал, поэтому его вспомогательная память +O(1); `Vec` остаётся только в тестовом оракуле под `#[cfg(test)]`. + +Compatibility profile `legacy-platform-dependent-v1` проверяет states в +порядке alpha и выбирает: + +1. первое состояние, где `|ΔJ′| >= target`; +2. если такого нет — глобальный максимум `|ΔJ′|`; +3. при равном максимуме — первое состояние по alpha. + +Каждая граница уже определена зафиксированным binary64-выражением в том же +порядке, что официальный JS-потребитель. Production-alpha берётся из +representable внутренности фактического интервала (или из замкнутого singleton +`alpha=1`) и перед возвратом обязана воспроизвести выбранный state. + +Этот legacy-выбор не зависит от предположения о монотонности J′, но остаётся +platform/libm-dependent: точного эталона или sound outward bound для CAM16- +ветвления пока нет. Поэтому `stable-v1` на нетривиальном site +`glow-target-or-maximum-v1` не выбирает state и возвращает +`NumericalDecisionV1::Indeterminate { site_id, evidence }`, где evidence — +`SoundBoundUnavailable`. WASM-проекция выводит из этого варианта согласованную +пару `reason: sound-bound-unavailable` + `bounds: unavailable`. Legacy не +включается как fallback — его обязан явно выбрать клиентский контракт. + +Единственное stable-исключение не является специальной цветовой эвристикой: +если point screen-композит не может изменить ни один байт при любой alpha, +`ΔJ′ = 0` следует из равенства byte-state. Такой no-op determinate имеет +`bit-exact` guarantee без вызова CAM16. Реестр уже мигрированных +branch-sensitive sites и классов гарантий является публичными данными +`numerical_registry_v1()` (#281); он не объявляет полный аудит исторических +`f64`-ветвлений, которым владеет #291. + +### 3. Alpha канонизируется внутри выбранного интервала + +Каждое квантованное состояние имеет интервал `[lower, upper)`, последнее — +`[lower, 1]`. Каноническая alpha — середина интервала: она максимизирует +численный запас до обеих стенок, но не объявляется эстетическим оптимумом. + +`alphaCss` — кратчайшая десятичная запись, восстанавливающая тот же `f64`. +CSS-переменная alpha копирует её буквально. Повторное округление downstream +запрещено. Побитовый parse-round-trip проверяется в Rust/JS-тестах; production +не включает универсальный десятичный парсер только ради повторной проверки +контракта стандартного shortest-roundtrip сериализатора. + +### 4. Point-метрики не подменяют spatial effect + +Цель glow относится только к изолированному halo. Результат отдельно сообщает +композит и `|ΔJ′|` для halo и core, `targetStatus` и `constraintLayer`. +`compositeProfile=encoded-srgb8-screen-v1` вместе с +`compositeGuarantee=bit-exact` описывает только конечный point-композитор. +`layerRecipeProfile=cam16-jprime-oklab-cusp-v1` идентифицирует алгоритм +построения core/halo. `appearanceDiagnosticProfile=cam16-ucs-jprime-li2017-v1` +идентифицирует модель reported `haloAchievedDj`/`coreAchievedDj` и потому +присутствует у полного resolved result. `selectionDiagnosticProfile` называет +модель только если она участвовала именно в target/max выборе; у exact no-op он +равен `null`. `decisionProfile` / `decisionGuarantee` отдельно сообщают, на +каком основании принят semantic verdict. Один общий profile был бы ложным +повышением recipe или диагностической модели до exact-гарантии. Совместный +blur/overlap/backdrop/HDR-эффект без геометрии не сертифицируется; это отдельный +`SpatialField` из #221. + +Midpoint J′ используется только как seed Oklab-светлоты, а gamut-boundary +chroma — как recipe core v1. Эмитированный core отдельно измеряется и не +объявляется точным midpoint после смены координат и sRGB8-квантования. Это не +проверенный на наблюдателях «закон красивого свечения». + +### 5. Доказательства являются исполняемыми + +- все 65 536 пар одного screen-канала исчерпывающе проверяют first-passing + binary64-разбиение против публичного compositor; отдельный ULP-seam тест + запрещает склейку алгебраически равных рациональных стенок; +- независимый finite-state oracle проверяет legacy-выбор reached/unreachable; +- source-over и screen при `alpha=0.122` сверяются с независимой точной + рациональной формулой на всех 65 536 парах байтов каждый; +- JS chain требует нулевого расхождения engine ↔ официальный consumer; +- stable-путь на нетривиальном site возвращает typed `Indeterminate`, не + обращается к CAM16 и не эмитит CSS-переменные; exact no-op остаётся + determinate с `bit-exact` guarantee; +- OKLCH и Display P3 проходят решётку с шагом 5 по каждому каналу (около + 140 тысяч цветов на путь, включая края) и полный серый ramp; +- изменение precision имеет быстрые mutation-killer sentinels. + +Полный куб из 16 777 216 входов не заявляется пройденным: выборочная решётка не +подменяет exhaustive-доказательство. Дальнейший conformance-gate отслеживает +`#258` и обязан фиксировать target/toolchain, если такой перебор будет добавлен. + +## Последствия и миграция + +Изменение намеренно breaking до 1.0 и не публикуется как patch (#259). + +Rust: + +- `composite_over_encoded(...)` теперь возвращает `Result`; +- для финального байтового reference используется `composite_over_srgb8(...)`; +- `screen_layer_over_encoded(...)` остаётся непрерывной алгеброй, а финальный + point-пиксель вычисляет `screen_layer_over_srgb8(...)`; +- `resolve_alpha_analog_hex(...)` возвращает тотальный для валидного домена + `Result<(String, f64), String>` вместо `Result, String>`; +- поля `GlowSolve` читаются через getters; +- `solve_screen_alpha_for_dj(...)` требует `GlowDecisionProfileV1` и возвращает + `NumericalDecisionV1`; +- alpha берётся из `alpha_css()`, статус — из `status()`, exact composite — из + `composite_certificate()`. + +WASM/TypeScript: + +- каждый Glow-рецепт обязан явно нести `decision_profile`; отсутствие и + неизвестная строка отвергаются при `loadConfig`, а профиль входит в fingerprint; +- determinate mocks обязаны добавить `alphaCss`, `constraintLayer`, `targetDj`, + `targetStatus`, оба composite, оба `achievedDj`, а также раздельные + `compositeProfile`, `compositeGuarantee`, `layerRecipeProfile`, + `appearanceDiagnosticProfile`, nullable `selectionDiagnosticProfile`, + `decisionProfile`, `decisionGuarantee`; +- exhaustive union обязан обрабатывать `kind: "glow-indeterminate"`; этот + terminal outcome несёт site/reason/bounds, но не создаёт halo/core/alpha vars; +- legacy `achievedDj` и `degraded` остаются точными aliases на период миграции; +- изменение строк alpha и соответствующих golden ожидаемо. + +При выпуске breaking-версии по #259 общий conformance pack должен быть повышен +до `2.0.0`: alpha-family должна содержать half-tie +`#C0B2FA @ 0.122` над чёрным, поэтому Rust FFI и все нативные биндинги обязаны +вернуть `#17161F`, а старый нормализованный путь больше не считается conformant. + +Полный переход и rollback описаны в `docs/migrations/exact-alpha-glow.md`. +Открытыми остаются: полный viewing/context contract (#230), sound cross-runtime +CAM16 bounds (#223/#281), фактический renderer/output pipeline (#233/#241) и +пространственная модель glow (#221). diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index 97db5414..8c393e1c 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -20,8 +20,8 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 17 | `CUSP_HALF_WINDOW_DEG` | `40.0` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** (НЕ (c), проверено ре-аудитом 2026-07-07) — Полу-окно поиска оттенка (°) в `cusp_attracted_hue`; держит undertone в сине-фиолетовой полосе. ⚠️ Замер: полный дрейф каспа гамута sRGB вблизи 286° достигает ≈42.5° по L-шкале — окно 40° клипует чуть ВНУТРИ полного дрейфа НАМЕРЕННО (движок держит оттенок близко к каноническому, не гонится за магента-каспом; см. комментарий `cusp_attracted_hue`). НЕ «окно покрывает дрейф»; тест `derivator_locks::cusp_window_is_near_measured_gamut_drift`. Калибровочный. **Отличие от `HUE_SEARCH_HALF_WINDOW` (строка 36):** там доказан интерьерный оптимум (окно НЕ связывает), здесь доказано ОБРАТНОЕ (дрейф строго превышает окно) — измеримо связывающий кап, значит точное значение окна материально для крайних светлот; (c) не применим. | | 19 | `STRICT_STEP` | `0.5` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** (НЕ (c), проверено ре-аудитом 2026-07-07) — Граница квантования выходной сетки (Lc), НЕ JND-клейм. Замер: медианный Lc-шаг одного 8-бит кванта серых ≈0.44; STRICT_STEP=0.5 сидит чуть выше типичного шага сетки. (Максимальный шаг ≈7.85 — это обрыв мягкого клампа APCA, разрыв, а не шаг сетки.) Тест `derivator_locks::strict_step_sits_just_above_typical_grid_step`. Калибровочный. **Почему не (c):** в отличие от других низко-экспозиционных ручек, `STRICT_STEP` — прямое слагаемое цели демоции (`senior_mag − STRICT_STEP` в `demote_below`/`demote_below_hued`), поэтому оно НЕПРЕРЫВНО и напрямую сдвигает эмитируемый цвет junior-роли; никакого лока инвариантности выхода по интервалу не существует — честный терминал (e), не (c). | | 21 | `CHROMA_FRACTION` | `0.88` | `sentiment.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — доля максимума хромы в гамуте, которую несёт каждый сентимент на своей матч-светлоте; `< 1`, чтобы сидеть внутри стены гамута, а не «неоном». **Волна 2:** genuine свободная ручка; легальный диапазон **(0,1]** (валидатор). Sensitivity: свип [0.70,1.0] на реальных якорях danger/warning/success/info → max ΔE_ok≈**0.0421** (>1 JND, материальна) → честный (e), не (c) (лок `chroma_fraction_sensitivity_is_bounded`). Реестровый дефолт 0.88 — для клиентов без якорной калибровки; **labui=1.0** (danger `#FF3B30` > 0.88·C_max, см. S-01). Протокол: подобрать так, чтобы самый насыщенный якорь клиента сел чуть внутри стены. **(Строка #20 `DEFAULT_HARDNESS` УДАЛЕНА Волной 1: p-норма brand-displacement снесена законом категориальных зон — трекать константу удалённого механизма было бы ложью о живости кода.)** | -| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | `1.75` | `neutral.rs` | SSOT-TRACKED | **OPEN ROOT MODEL — scalar gamma rejected.** `1.75` remains only as the frozen legacy-output baseline. Equal-step fits in Oklab, encoded sRGB and surround-CAM16 J′ miss the shipped magnitude; the direct Whittle/crispening fit is also refuted because it becomes affine above `L_b` and singular toward the dark anchor. It is neither an admitted universal law nor an active Whittle hypothesis. Owner #219/#261 must derive a context-conditioned progression or remove gamma parameterisation. Sensitivity: [1.3,2.2] changes emitted output materially (max ΔE_ok≈0.0466). | -| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | `1.5` | `neutral.rs` | SSOT-TRACKED | **OPEN ROOT MODEL — scalar gamma rejected.** `1.5` remains only as the frozen legacy-output baseline. Equal-step Oklab/sRGB/CAM16-J′ fits and the direct Whittle/crispening fit all fail by a large margin; the latter runs away from the near-black singularity instead of explaining the progression. Owner #219/#261 must derive a context-conditioned progression or remove gamma parameterisation. Sensitivity: [1.2,1.9] changes emitted output materially (max ΔE_ok≈0.0325). | +| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | `1.75` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE / COMPATIBILITY POLICY** — `1.75` заморожено как база совместимости прежней эмиссии; это терминальный провенанс shipped-значения, не универсальный закон. **НАУЧНАЯ ЗАМЕНА: OPEN** — равные шаги Oklab, кодированного sRGB, CAM16 J′ с окружением и прямое прочтение метрики Уиттла опровергнуты по магнитуде/вырождению. #219/#261 могут исследовать человеко-ориентированную замену; generic fact-only pipeline от неё не зависит. Свип [1.3,2.2] материален (max ΔE_ok≈0.0466). | +| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | `1.5` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE / COMPATIBILITY POLICY** — `1.5` заморожено как база совместимости прежней эмиссии; это терминальный провенанс shipped-значения, не универсальный закон. **НАУЧНАЯ ЗАМЕНА: OPEN** — Oklab/sRGB/CAM16-J′ и прямое прочтение метрики Уиттла дают большой промах; последнее уходит от сингулярности около чёрного вместо объяснения прогрессии. #219/#261 могут исследовать человеко-ориентированную замену; generic fact-only pipeline от неё не зависит. Свип [1.2,1.9] материален (max ΔE_ok≈0.0325). | | 24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | `0.35` | `neutral.rs` | SSOT-TRACKED | **КОНТЕКСТ-ФУНКЦИЯ (hue) ПОДТВЕРЖДЕНА** (co-dependency, #95 2026-07-08 — секция внизу): `chroma_peak_t(hue) =` t светлотного cusp'а; на акцентах хорошо поставлена, диапазон [0.225, 0.479], среднее **0.379 ≈ shipped 0.35**. Скаляр = приближение центра hue-закона; нейтраль = вырождение (atan2-шум). Эмиссионно-НЕЙТРАЛЕН → hue-функцию можно ввести БАЙТ-ИДЕНТИЧНО (кандидат в объективизацию, не semver-major). Позиция t пика огибающей хромы; легальный диапазон `(0,0.5]` (клампится). Sensitivity: свип [0.2,0.5] → max ΔE_ok≈**0.0042** (< ½ JND, лок `curve_params_sensitivity_is_bounded`). | | 25 | `SOFT_CLAMP_THRESHOLD` | `0.022` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | | 26 | `SOFT_CLAMP_EXP` | `1.414` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | @@ -49,7 +49,7 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 53 | `QUANT_GUARD` | `0.2` | `semantic.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — квант-guard над `MODEL_LC_FLOOR` (7.3): зазор `DECORATIVE_FLOOR_MIN − MODEL_LC_FLOOR`, держащий декоративный пол строго выше 7.3, чтобы решатель не садился на инфимум клипа и не возвращал нулевой контраст (issue #44). **Доказательство сильнее обычного (c):** `QUANT_GUARD` НЕ используется в продакшене НЕЗАВИСИМО — единственная величина, которую видит решатель, это байт-идентичный литерал `DECORATIVE_FLOOR_MIN` (7.5); guard существует только как провенанс-разложение этого литерала на DERIVED-член (7.3) и задекларированный запас (лок `decorative_floor_is_model_floor_plus_guard`, компайл-тайм `const _`). Замер (`no_pair_emits_contrast_below_model_floor`, скан квантованных пар): фактический минимальный ненулевой контраст = **7.3005** (сидит 0.0005 над модельным инфимумом 7.3, а не у номинальной цели 7.5) — скачок |Lc| одного 8-бит шага у клипа = **7.85** (жёсткий разрыв, guard не покрывает и не должен). Пока guard положителен и цель остаётся достижимой в пределах `QUANT_BUDGET` = 1.0, точная магнитуда 0.2 не меняет ни одного реального эмитируемого контраста; доминантный член 7.3 остаётся DERIVED. | | 54 | `MODEL_LC_FLOOR` | `7.3` | `lpc.rs` | SSOT-TRACKED | **(a) DERIVED** = (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE = (0.1 − 0.027) × 100 = 7.3 — минимальный ненулевой контраст модели (за низкоконтрастным клипом `contrast_core` стартует ровно с него, внутри клипа — ноль; полярности симметричны, `LO_WOB_OFFSET == LO_BOW_OFFSET`). Алгебраическая идентичность из GROUNDED APCA `0.0.98G-4g` набора; хранится литералом 7.3 (чистое число для инвентаря), тождество с формулой пиннится `model_lc_floor_is_the_published_clip_minimum` (дрейф любого из трёх APCA-входов или правка литерала ломает лок). Опора `DECORATIVE_FLOOR_MIN` (строка 1), issue #44. | -## Muddiness Law constants — `cleanliness.rs` +## Замороженный experimental compatibility proxy — исторический API `cleanliness.rs` > **GATE SCOPE NOTE:** `cleanliness.rs` is NOT in the `PERCEPTUAL_MODULES` audit surface of > `tests/empirical_inventory.rs` (that gate covers `semantic.rs`, `scale.rs`, `sentiment.rs`, @@ -64,24 +64,26 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- > gate** and does not exist on this tree. The module header in `cleanliness.rs` cross-references > each constant to its row here. -Science-vs-fit boundary: the **structural form** (warm gate b=C·sin h, neutral gate sigmoid on C, depth term below cusp-L) is derived from Oklab opponent-colour theory and CAM16-UCS. **The confidence layer (M_W / KAPPA_CORE / KAPPA_INTERIOR, M-09/10/11) has been removed entirely** (Front B, 2026-07-04): it was a single-observer DECLARED-CALIBRATION with no reliability model, off the emission path and outside the agnostic contract — see the REMOVED rows below. `muddiness` (distance geometry) remains. The former M-03 light-escape threshold has been **removed entirely** (Zone B, 2026-07-01): its escape term leaked the lightness axis into the neutral gate, which is documented as chroma-only; `neutral_gate` is now `sigmoid((C - C0) / JND)`. **B0 and BW are cited-measured** (Zone B, 2026-06-29). **Platt scalars CAL_T/CAL_B/CAL_EPS (M-06/M-07/M-08) have been removed** (Zone B slice 3, 2026-06-30). **W_HUE[8] and CEIL_N_TABLE have been replaced** (Zone B slice 4, 2026-06-30) by the derived Bezold-Brücke Hanning window `hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2`; H_Y_DEG = 90.4011° (Oklab hue of unique yellow λ=578nm, CIE 1931 2° D65, XYZ→Oklab direct — corrected in Zone B slice 5, 2026-07-03: the slice-4 value 96.9172° rested on transposed CMF rows and a hue-distorting sRGB clamp, and its citation «Parry (1967) JOSA 57, 1130–1134» does not exist; real sources: Purdy (1937) Am. J. Psychol. 49, 313–315; Jacobs & Wascher (1967) JOSA 57, 1155–1156). Zero fitting. CUSP_L_TABLE is cited-and-kept: pure sRGB-gamut geometry, kept as-is per paradigm North. +Эпистемическая граница: shipped-формула — **замороженный experimental compatibility proxy / research coordinate**. Её арифметика и committed corpus воспроизводимы, но число не является observer-validated оценкой cleanliness/murkiness, вероятностью, эстетическим вердиктом или product contract. Базовый компилятор и adaptive runtime эту координату не используют. Множители (сигмоида Oklab-chroma, positive-`b` gate, hue-окно и глубина ниже sRGB/Oklab cusp) — детерминированные математические признаки; ни один опубликованный источник не выводит их произведение как человеческую перцептивную шкалу. + +Confidence-слой (M_W / KAPPA_CORE / KAPPA_INTERIOR, M-09/10/11), Platt-скаляры (M-06/M-07/M-08), fitted W_HUE и CEIL_N_TABLE удалены. Это удаление не доказывает provenance оставшихся констант. В частности, названная литература не выводит точные Oklab-значения C0/JND/B0/BW; Hanning-окно — явная эвристика, а не следствие эффекта Бецольда–Брюкке; преобразование выбранного 578 nm reference в Oklab допустимо как арифметика, но не устанавливает универсальный observer unique-yellow; sRGB/Oklab cusp — геометрия гамута, а не человеческая clean/dirty-граница. Владелец эпистемической/API-миграции — [#231](https://github.com/Labpics-Team/lab-colors/issues/231); применимость JND и evidence возможной замены принадлежат [#242](https://github.com/Labpics-Team/lab-colors/issues/242). | mud-id | name | value | module | status | rationale | |--------|------|-------|--------|--------|-----------| -| M-01 | `C0` | `0.0395` | `cleanliness.rs` | cited-and-kept | Grey-frontier chroma floor — центр сигмоида перехода ахроматический→хроматический (`sigmoid((C−C0)/JND)`). Единственное честное основание: литература о пороге «нулевой серости» / хроматического порога (Evans, «The Perception of Color»; Xie & Fairchild). **⚠️ Прежняя геометрическая мотивировка** («граница гамута sRGB для серых ≈0.04 при L=0.5») **удалена как некорректная**: у ахроматических серых C=0 по определению, «границы гамута для серых при C≈0.04» не существует. Строгий вывод значения 0.0395 в Oklab из Evans/Xie-Fairchild НЕ установлен — **TODO вывода**. cited-and-kept, значение не меняется (North). Bound: 0.030 ≤ C0 ≤ 0.050 (декларированный диапазон). | -| M-02 | `JND` | `0.012278` | `cleanliness.rs` | cited-and-kept | Chroma just-noticeable difference in Oklab. Oklab is perceptually uniform to first order; one JND on the opponent axes a,b is ≈ 0.012 (derived from the ΔE₂₀₀₀ ≈ 1 JND mapped through the Oklab linearisation). JND = 0.01228 is used as the sigmoid transition width for both the neutral gate and the b-gate, encoding that transitions span ≈ 1 perceptual unit. Cited: Oklab perceptual measurement. Kept as-is per paradigm North. **⚠️ Оговорка:** отображение ΔE₂₀₀₀→Oklab использует линеаризацию (якобиан), но точка пространства, в которой якобиан взят, НЕ зафиксирована в первоисточнике деривации (ΔE₂₀₀₀↔Oklab зависит от точки); значение держится как задекларированное и не округляется. Bound: 0.010 ≤ JND ≤ 0.016. | -| M-04 | `B0` | `0.036` | `cleanliness.rs` | cited-measured | Oklab-b warm-gate centre. Structural form (b = C·sin h threshold) is derived from Oklab opponent-colour theory: the yellow–blue axis b separates warm (b > 0) from cool (b < 0). Value 0.036 is the central of the cited range [0.030, 0.044]: the empirical yellow–brown perceptual boundary from unique-hue experiments (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). Observer-fit replaced by cited-measured central (Zone B). | -| M-05 | `BW` | `0.017` | `cleanliness.rs` | cited-measured | Oklab-b warm-gate transition width. Same structural role as JND but for the b-axis warm gate. Value 0.017 is the central of the cited range [0.013, 0.020]: the perceptual transition width of the warm–cool boundary from the same unique-hue studies (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). Observer-fit replaced by cited-measured central (Zone B). | +| M-01 | `C0` | `0.0395` | `cleanliness.rs` | Indeterminate provenance | Замороженный центр сигмоиды compatibility proxy. Ни литература о порогах, ни геометрия гамута sRGB не выводят точное Oklab-значение `0.0395`; прежнее утверждение об универсальной grey frontier отклонено. Сохранение числа удерживает research coordinate и не является evidence человеческого смысла ([#231](https://github.com/Labpics-Team/lab-colors/issues/231)). | +| M-02 | `JND` | `0.012278` | `cleanliness.rs` | universal JND Rejected; Indeterminate value | Замороженная ширина сигмоиды. Универсальный Oklab JND отклонён: перенос из ΔE₂₀₀₀ требует заданных reference point, направления, observer population и viewing domain; без них не выводится ни это число, ни его применение как logistic width. Evidence и применимость возможной замены принадлежат [#242](https://github.com/Labpics-Team/lab-colors/issues/242). | +| M-04 | `B0` | `0.036` | `cleanliness.rs` | Rejected provenance; Indeterminate value | Замороженный центр positive-`b` gate этого proxy. Newhall–Nickerson–Judd, Lindsey–Brown и Boynton не выводят заявленный Oklab-`b` range или точное значение `0.036`; прежнее literature-grounded утверждение о границе отклонено. Число остаётся только ради совместимости ([#231](https://github.com/Labpics-Team/lab-colors/issues/231)). | +| M-05 | `BW` | `0.017` | `cleanliness.rs` | Rejected provenance; Indeterminate value | Замороженная ширина positive-`b` gate этого proxy. Названные источники не выводят Oklab transition-width range или точное значение `0.017`; прежний provenance отклонён. Число остаётся только ради совместимости ([#231](https://github.com/Labpics-Team/lab-colors/issues/231)). | | M-06 | `CAL_EPS` | `0.01` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link log-offset — removed together with the Platt sigmoid chain. `pub const CAL_EPS` no longer exists in `cleanliness.rs`; any reference is test-only (absent-constant guard). | | M-07 | `CAL_T` | `2.356978` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link scale — removed. Was a fitted scalar on v3 train split; violated ZERO observer-fit invariant (North). `pub const CAL_T` no longer exists in `cleanliness.rs`. | | M-08 | `CAL_B` | `6.445168` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link bias — removed. Was a fitted scalar on v3 train split; violated ZERO observer-fit invariant (North). `pub const CAL_B` no longer exists in `cleanliness.rs`. | | M-09 | `M_W` | `0.181527` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Полуширина доверительного поля confidence-слоя. Была DECLARED-CALIBRATION из однонаблюдательской разметки владельца (N=1, 738 меток, reliability-модели нет) — observer-fit вне агностичного контракта движка и вне пути эмиссии (`confidence` не звался из solve/semantic/resolve). Слой удалён целиком, НЕ ре-калиброван (датасета для честного мультинаблюдательского ре-груна нет — любая новая калибровка была бы театром). `pub const M_W` больше не существует в `cleanliness.rs`; снос байт-идентичен эмиссии. | | M-10 | `KAPPA_CORE` | `0.34` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Потолок уверенности стабильного ядра confidence-слоя — та же однонаблюдательская калибровка (N=1), без модели надёжности. Удалён вместе с confidence-слоем; `pub const KAPPA_CORE` больше не существует в `cleanliness.rs`. | | M-11 | `KAPPA_INTERIOR` | `0.10` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Пол уверенности спорной полосы confidence-слоя — та же однонаблюдательская калибровка (N=1), без модели надёжности. Удалён вместе с confidence-слоем; `pub const KAPPA_INTERIOR` больше не существует в `cleanliness.rs`. | -| M-12 | `H_Y_DEG` | `90.4011` | `cleanliness.rs` | cited-derived (Zone B slice 5, 2026-07-03) | Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65). Вывод: CMF при 578nm — линейная интерп. CIE 5нм-таблицы (575nm: 0.8425/0.9154/0.0018; 580nm: 0.9163/0.8700/0.00165) → (0.886780, 0.888160, 0.001710) → XYZ/Y = (0.998446, 1, 0.001925) → Oklab НАПРЯМУЮ (Ottosson 2020; Oklab hue инвариантен к радиансности, sRGB-проекция не нужна; покомпонентный клэмп НЕ сохраняет hue) → atan2(b,a) = 90.4011° (±0.01° — неопределённость 5нм-интерполяции: 1нм-таблица даёт 90.3925°). Центр Hanning-окна `hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2`; производная поворота Бецольда-Брюкке: dΔH_BB/dh = A_BB·cos(h−h_Y). История: значение slice 4 (96.9172°) ОТОЗВАНО — CMF-строки были перепутаны (заявленные ȳ=0.7070/0.7570 не существуют на 570/580нм; ложный XYZ=(1.207,1,0)), hue брался после sRGB-клэмпа (артефакт: 69.7°→96.9° от клэмпа), а цитата «Parry (1967) JOSA 57, 1130–1134» не существует. Реальные источники: инвариантная точка жёлтого ≈578nm — измерения BB-сдвига Jacobs & Wascher (1967) J. Opt. Soc. Am. 57, 1155–1156; сама рамка инвариантных оттенков (contours for constant hue) — Purdy (1937) Am. J. Psychol. 49, 313–315 (атрибуция уточнена по ревью 2026-07-03: Purdy — феномен BB/контуры, не прямое измерение 578nm). | -| M-12 | ~~`W_HUE[8]`~~ | _(удалён)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Был: K=3 Fourier-базис + CEIL_N, подогнанный логистической регрессией на 738 авторских метках v3. Нарушал ZERO observer-fit (North). Заменён Hanning-окном BB (H_Y_DEG). | -| M-13 | `CUSP_L_TABLE[361]` | see code | `cleanliness.rs` | cited-and-kept | Oklab cusp lightness per integer hue degree (0–360). Derived from the sRGB gamut geometry (kept as-is per paradigm North, never refit): for each hue angle h the cusp L is the Oklab L of the maximum-chroma sRGB-boundary point at that hue. This is a deterministic geometric quantity (no fitting). Reproduced via the Oklab gamut-intersection algorithm (find_cusp in the Björn Ottosson reference implementation). Bound: values lie in [0.45, 0.97] as per full sRGB gamut sweep. | -| M-14 | ~~`CEIL_N_TABLE[361]`~~ | _(удалена)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Была: нормированный CEIL_N-коэффициент (второй Fourier-базис). Подогнана на v3 для захвата несинусоидальной вариации потолка грязи; не выводима из первых принципов. После замены hue_weight на BB-формулу более не нужна. | +| M-12 | `H_Y_DEG` | `90.4011` | `cleanliness.rs` | arithmetic admitted; observer claim Rejected | Арифметика admitted для объявленного frozen reference: интерполяция выбранных CMF при 578 nm, нормировка XYZ/Y и преобразование этого стимула в Oklab дают примерно `90.4011°` с указанными матрицами. Утверждение об универсальном observer unique-yellow отклонено: unique hue зависит от наблюдателя, luminance и протокола. Центрированное здесь Hanning-окно — явная эвристика; измерения Бецольда–Брюкке не выводят это окно или человеческую cleanliness-coordinate ([#231](https://github.com/Labpics-Team/lab-colors/issues/231)). | +| M-12 | ~~`W_HUE[8]`~~ | _(удалён)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Был K=3 Fourier-базис, подогнанный логистической регрессией на 738 авторских метках v3. Заменён замороженной heuristic Hanning-формой с центром `H_Y_DEG`; это не делает форму выводом из эффекта Бецольда–Брюкке. | +| M-13 | `CUSP_L_TABLE[361]` | see code | `cleanliness.rs` | geometry admitted; observer frontier Rejected | Детерминированная геометрия гамута sRGB/Oklab: для каждого целого hue таблица хранит cusp lightness максимального по chroma граничного sRGB-цвета. Эта воспроизводимая геометрия admitted; её интерпретация как psychophysical clean/dirty frontier отклонена ([#231](https://github.com/Labpics-Team/lab-colors/issues/231)). | +| M-14 | ~~`CEIL_N_TABLE[361]`~~ | _(удалена)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Была подогнанным Fourier-коэффициентом legacy proxy и не выводилась из первых принципов. После перехода на heuristic Hanning-форму более не используется. | | S-01 | `chroma_fraction` (тинт) | конфиг `(0,1]` | `sentiment.rs` | applied (2026-07-03) | Анти-неоновый потолок продакшн-тинта сентимента: `c = min(c_якоря, f·C_max(L, h_решённый))`. Хрома якоря клиента — авторитет идентичности; потолок кусается только на неоново-насыщенном якоре или суженном гамуте смещённого оттенка. Одновременно закрыт молчаливый канальный клип sRGB (искажал оттенок; усечение теперь по оси хромы). labui-референс ставит `1.0` (чистая стена гамута: Figma-якорь danger #FF3B30 сидит выше 0.88·C_max); реестровый дефолт 0.88 — для клиентов без якорной калибровки. | | S-02 | Warning↔Success light-ic | ab ≈ 0.042 → ВЫЛЕЧЕНО | `sentiment.rs`, `config.rs` | RESOLVED (2026-07-03, тот же день) | Находка попарного аудита: брендоцентричный закон был попарно слеп, а flip-ветка при ДАЛЁКОМ бренде зеркалила Warning через полкруга (IC-якорь #C93400 ниже пола 45° → телепорт в зелень #508300 к Success #248A3D, ab 0.042 < 0.0687). Лечение — многотельная легальность: `NeighborZone` в is_legal/legalize/resolve + двухфазная оккупация `sentiment_solid_for_mode` (покоящиеся сентименты — неподвижные оккупанты, идентичность якорей цела по построению; смещённые держат угловой отступ — инверсия хорды s_perc_min при средней хроме пары). Warning light-ic ложится в янтарную дугу у пола. Пины: `labui_sentiment_solids_keep_pairwise_ab_distance` (все пары ≥ порога, без исключений), `warning_light_ic_heals_into_amber_arc_not_green` (янтарь + RED-proof однотельного закона). | @@ -207,11 +209,12 @@ reference-vectors/chain-invariants/r3 байт-идентичны (продак ### Follow-on: реоткрытие CurveParams — парадигма co-dependency (`science/e-derivation-wave` #95, 2026-07-08) -**Смена парадигмы (владелец 2026-07-08): «дизайнерского цвета не бывает».** Терминал -(e) DESIGN-CHOICE для СОЗАВИСИМОЙ константы ОТМЕНЁН как окончательный: при рефуте -СКАЛЯРНОЙ деривации константа становится КАНДИДАТОМ В ФУНКЦИЮ КОНТЕКСТА, а не -свободной ручкой. Статус `#22/#23` (гаммы) и `#24` (chroma_peak_t): **«закон не -найден ЕЩЁ, активные гипотезы»** (не терминал (e)). +**Смена парадигмы (уточнено research lock #281): «дизайнерского цвета не бывает».** +Научный статус и провенанс shipped-значения — разные оси. Для `#22/#23` терминал +**(e) COMPATIBILITY POLICY** фиксирует прежние байты, а человеко-ориентированная +замена остаётся OPEN после рефута скалярных гипотез. Для `#24` подтверждён +кандидат функции контекста. Ни один из этих исследований не является условием +работоспособности generic fact-only pipeline. **#22/#23 `gamma_light/dark` — четыре метрики step-uniformity замерены, все четыре РЕФУТИРУЮТ скалярные прочтения; закон не найден** (эксперимент `_e95_*`, убран после @@ -252,7 +255,7 @@ reference-vectors/chain-invariants/r3 байт-идентичны (продак gamma эмиссионно-МАТЕРИАЛЬНА (перебор ломает `accent_curve_007af_sample_hex_13`), потому форсить любую из деривации = подлог (сменил бы отгружаемые байты). Все четыре испробованные скалярные/параметр-free метрики рефутированы по магнитуде — - закон гамм остаётся открытым (не терминал (e), см. мета-правило ниже); + научная замена гамм остаётся OPEN при терминальном compatibility-провенансе (e); требуется дальнейшая контекстная гипотеза (surround × crispening совместно, или иная физическая модель), либо явное признание владельцем терминала после исчерпания. @@ -282,13 +285,13 @@ reference-vectors/chain-invariants/r3 байт-идентичны (продак (не semver-major). Кандидат в объективизацию: `chroma_peak_t = f(hue) =` cusp- светлота, вырождение для нейтралей. -**МЕТА-ПРАВИЛО (уточнение таксономии, владелец 2026-07-08):** созависимая константа -+ рефут СКАЛЯРНОЙ деривации → КАНДИДАТ В ФУНКЦИЮ КОНТЕКСТА, не терминал. Терминал (e) -DESIGN-CHOICE допустим ТОЛЬКО после исчерпания контекстных гипотез (surround / фон -темы / hue / …). Это уточняет «бакета „нужен эксперимент“ не существует»: контекстная -гипотеза — не выдуманный будущий эксперимент с наблюдателями, а ПРОВЕРЯЕМЫЙ на движке -замер (как E1/E2); отклонённая СКАЛЯРНАЯ деривация НЕ закрывает вопрос, пока -контекстные функции не исчерпаны. Прецедент рефута скалярного кандидата — +**МЕТА-ПРАВИЛО (уточнено research lock #281):** рефут скалярной деривации +созависимой константы открывает кандидат функции контекста, но не стирает +провенанс уже отгруженного значения. Научный статус может оставаться OPEN, пока +совместимость честно закрыта терминалом (e) и generic fact-only путь не выдаёт +legacy-политику за человеческий закон. Контекстная гипотеза — проверяемый на +движке замер (как E1/E2), а не обещание будущего эксперимента. Прецедент рефута +скалярного кандидата — `HUE_DRIFT_PENALTY_SLOPE` (#37, хорда Oklab отклонена замером) — остаётся валидным для НЕ-созависимых ручек; разница в том, что у CurveParams контекст (фон/hue) есть и обязан быть исчерпан прежде терминала. diff --git a/docs/empirical-residue.md b/docs/empirical-residue.md index 91f74ac1..d368784b 100644 --- a/docs/empirical-residue.md +++ b/docs/empirical-residue.md @@ -53,7 +53,7 @@ Итог волны objectivization (историческая рамка): (a)=4, (b)=0, (c)=7, (d)=6, (e)=8. **После follow-on `science/floor-derivation` (мишени #3/#4, +`QUANT_GUARD` -#53, +`MODEL_LC_FLOOR` #54), `science/crossover-terminal-e` (#52 → (e)), +`#53`, +`MODEL_LC_FLOOR` #54), `science/crossover-terminal-e` (#52 → (e)), `science/purity-terminal-e` (#48/#49 → (e)), `science/registry-coherence` (#37 → (e), остальные (c) → (e), временно), `science/reclassify-e-buckets` (6 бывших (c)-строк ВОЗВРАЩЕНЫ в (c) по измерению; #37 несёт MODEL-CONFLICT-пометку) @@ -68,7 +68,11 @@ дополнительно исключил `#18 LIGHTNESS_SETTLE`: контрпример опроверг его класс (a), а численная legacy-эвристика не является перцептивной policy-константой. Поэтому актуальный итог — 25, а не 26 или 27. -Внутри прежней классификации констант нет безымянных строк, но это не означает, что корневая модель neutral progression найдена: gamma-строки остаются frozen legacy baselines и открыты в #219/#261. +В прежней классификации констант нет безымянных строк, но это не означает, что +корневая модель нейтральной прогрессии найдена. Значения gamma имеют терминальный +провенанс **(e) COMPATIBILITY POLICY**: они заморожены для прежней эмиссии. +Отдельный научный вопрос о возможной человеко-ориентированной замене остаётся +OPEN в #219/#261 и не является блокером для generic fact-only pipeline. ## Классификация текущих 25 policy-констант @@ -101,8 +105,8 @@ | 12 | `NEUTRAL_HUE_DEG` | 286.0 | (e) | измер. якорь; Волна 2: байт-инвариант по разбросу [285.78,286.01], ±20°→0.0114 (~1 JND) | `neutral_hue_emits_byte_invariant_across_measured_family_spread`, `custom_tint_overrides_hue_and_ratio` | | 21 | `CHROMA_FRACTION` | 0.88 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0421 по [0.70,1.0]; labui=1.0; диапазон (0,1] | `chroma_fraction_sensitivity_is_bounded`, `chroma_fraction_out_of_bounds_is_rejected` | | ~~20~~ | ~~`DEFAULT_HARDNESS`~~ | — | — | **УДАЛЁН Волной 1** (снесён механизм p-нормы brand-displacement; config-поле `hardness` живо как vestigial, тест `hardness_below_one_is_rejected` держит его границу) | — | -| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | 1.75 | **OPEN** | frozen legacy baseline; four scalar explanations, including direct Whittle/crispening, are refuted by magnitude/degeneracy; context-conditioned progression or removal of gamma is owned by #219/#261 | `curve_params_sensitivity_is_bounded`, gamma derivation regressions | -| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | 1.5 | **OPEN** | frozen legacy baseline; four scalar explanations, including direct Whittle/crispening, are refuted, with especially strong failure near the dark anchor; context-conditioned progression or removal of gamma is owned by #219/#261 | `curve_params_sensitivity_is_bounded`, gamma derivation regressions | +| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | 1.75 | **(e)** | **COMPATIBILITY POLICY:** зафиксированная база прежней эмиссии; **НАУЧНАЯ ЗАМЕНА: OPEN** — четыре скалярных объяснения, включая прямое прочтение метрики Уиттла, опровергнуты по магнитуде/вырождению; #219/#261 могут исследовать человеко-ориентированную замену, но не блокируют generic fact-only pipeline | `curve_params_sensitivity_is_bounded`, регрессионные тесты дериваций gamma | +| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | 1.5 | **(e)** | **COMPATIBILITY POLICY:** зафиксированная база прежней эмиссии; **НАУЧНАЯ ЗАМЕНА: OPEN** — четыре скалярных объяснения, включая прямое прочтение метрики Уиттла, опровергнуты, особенно сильно около тёмного якоря; #219/#261 могут исследовать человеко-ориентированную замену, но не блокируют generic fact-only pipeline | `curve_params_sensitivity_is_bounded`, регрессионные тесты дериваций gamma | | 24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | 0.35 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0042 по [0.2,0.5] (низшая); диапазон (0,0.5] | `curve_params_sensitivity_is_bounded` | **(b) после follow-on = 1 (было 0) — честно.** `IC_DECORATIVE_FLOOR_MIN` = 15.0 @@ -112,9 +116,10 @@ JND. `DECORATIVE_FLOOR_MIN` (7.5) — не (b), а (a): выводится алгебраически из GROUNDED APCA-клипа, а не из отдельного опубликованного измерения. Прочие калибровочные константы прямого опубликованного измерения ИМЕННО своего значения -по-прежнему не имеют; литературно-заземляемые `B0`/`BW`/`H_Y_DEG` — это M-строки -`cleanliness.rs` (волна Zone B), вне этого набора. Выдумывать цитату под -остальные — подлог. +по-прежнему не имеют. `B0`/`BW`/`H_Y_DEG` — замороженные compatibility values +M-строк `cleanliness.rs` (волна Zone B), вне этого набора: прежние +literature-grounded / universal-observer claims отклонены, а не доказаны. +Выдумывать цитату под остальные — подлог. ## EXPOSURE-анализ @@ -352,13 +357,14 @@ GROUNDED APCA-клипа, а не из отдельного опубликова замером, не требует решения владельца. Разница с #52: там модельный якорь ЖДЁТ выбора, здесь альтернатива уже дисквалифицирована экспериментом. -**Генуинные (e) на конец `reclassify-e-buckets`, 9 явных** (плюс #17, #19 из +**Терминалы (e) на конец `reclassify-e-buckets`, 9 явных** (плюс #17, #19 из блока «две ложные кандидатуры» выше = 11 всего): `NEUTRAL_HUE_DEG` (#12), `NEUTRAL_TINT_RATIO` (#13), ~~`TINT_HUE_STIFFNESS` (#15)~~, `CHROMA_FRACTION` (#21), `NEUTRAL_DEFAULT_GAMMA_LIGHT` (#22), `NEUTRAL_DEFAULT_GAMMA_DARK` (#23), `NEUTRAL_DEFAULT_CHROMA_PEAK_T` (#24), -`HUE_PURITY_MP_REF_RATIO` (#48), `HUE_PURITY_EXPONENT` (#49) — ни для одного не -существует модельного якоря; провенанс формы там, где он есть (`HUE_PURITY_*`), +`HUE_PURITY_MP_REF_RATIO` (#48), `HUE_PURITY_EXPONENT` (#49). Для gamma это +замороженная compatibility policy, а не генуинная свободная ручка; для остальных +не существует модельного якоря. Провенанс формы там, где он есть (`HUE_PURITY_*`), не покрывает магнитуду (ADR-0002/INV-2 против фейкового (b)), и та же пара показывает материальный дрейф (max|Δpurity|=0.148) — магнитуда НЕ immaterial, честный терминал (e), не (c). **⚠️ Волна 2 (2026-07-08) ВЫВЕЛА `#15 TINT_HUE_STIFFNESS` diff --git a/docs/migrations/exact-alpha-glow.md b/docs/migrations/exact-alpha-glow.md new file mode 100644 index 00000000..e5c3b0f2 --- /dev/null +++ b/docs/migrations/exact-alpha-glow.md @@ -0,0 +1,387 @@ +# Миграция exact alpha / typed Glow + +> Роль: breaking-migration guide для `@labpics/colors` 0.10.0 и Rust workspace +> 0.2.0 с предыдущих `@labpics/colors` 0.9.1 / Rust 0.1.0. + +Release artifact собирается закреплённым Node 24.14/npm 11.9, но это не +consumer requirement: `@labpics/colors` поддерживает `Node >=22.11.0` (первый +Node 22 LTS), и этот floor проверяется отдельным CI job. + +Эта версия разделяет два контракта, которые раньше выглядели одним: + +1. exact encoded-sRGB8 point-композит и его воспроизводимые байты; +2. semantic target/max-выбор по CAM16-UCS J′, для которого пока нет sound + cross-runtime error bound. + +Обновление breaking: сначала поменяйте конфиг и exhaustive result handling, +затем обновляйте runtime. Не включайте `stable-v1`, если потребитель ещё +предполагает, что каждая Glow-роль всегда создаёт CSS-переменные. + +## Версии одного контракта + +| Артефакт | Было | Стало | +| --- | --- | --- | +| npm | `@labpics/colors` 0.9.1 | `@labpics/colors` 0.10.0 | +| Rust workspace | 0.1.0 | 0.2.0 | +| conformance pack | 1.0.0 | 2.0.0 | + +Версии npm, Rust и conformance pack проверяют разные границы, но для этого +breaking-перехода должны обновляться согласованно. + +## 1. Сделайте `decision_profile` обязательным + +Каждый client-owned Glow-рецепт теперь обязан явно выбрать numerical-decision +profile: + +```json +{ + "name": "client-owned-glow-id", + "recipe": { + "kind": "glow", + "source": { "kind": "brand" }, + "step": "base", + "decision_profile": "stable-v1" + } +} +``` + +Допустимы только две строки: + +| Значение | Контракт | +| --- | --- | +| `stable-v1` | Не выбирает нетривиальный CAM16 target/max state без sound bound; возвращает typed `Indeterminate`. | +| `legacy-platform-dependent-v1` | Явно сохраняет прежний CAM16/libm-dependent target/max-выбор и CSS-эмиссию. Это compatibility profile, не stable numerical guarantee. | + +Пропущенное или неизвестное значение отклоняется как `invalid_config`; default +и silent legacy fallback отсутствуют. `decision_profile` входит в canonical +fingerprint конфига, поэтому после изменения ожидаем новый fingerprint и новый +cache namespace. + +Для закреплённого полного Lab UI паспорта добавление explicit legacy profile +меняет fingerprint с `f2a892a62f7bc91e` на `c51445fcd167781a`. Это ожидаемая +смена identity при сохранении прежней Glow-эмиссии, а не drift якорей. Для +другого клиентского конфига вычисляйте собственный fingerprint — эти две строки +не являются универсальными значениями. + +Если главная задача первого rollout — сохранить прежнюю эмиссию, начните с +`legacy-platform-dependent-v1`. Переход на `stable-v1` делайте отдельным +изменением потребителя после добавления ветки `glow-indeterminate`. + +## 2. Обрабатывайте Glow как discriminated union + +```ts +const role = result.roles[roleId]; + +switch (role.kind) { + case "glow": + // Determinate: vars уже содержат halo/core/alpha. + renderGlowDiagnostics(role); + break; + case "glow-indeterminate": + // Terminal typed outcome: semantic state и CSS не выбраны. + reportNumericalIndeterminacy(role); + break; + // ...остальные варианты RoleResult... +} +``` + +Не превращайте `glow-indeterminate` в `unreachable` и не подставляйте последний +цвет, alpha=1 или legacy-результат. Это отдельный terminal outcome о +недостаточности численного доказательства. + +### `stable-v1`: нетривиальный запрос + +Для текущего branch-sensitive site форма результата такова: + +```ts +{ + kind: "glow-indeterminate", + cssVar: "--lab-client-owned-glow-id", + sourceHex: "#4A8FFF", + targetDj: 2.3006, + constraintLayer: "halo", + decisionProfile: "stable-v1", + numericalSiteId: "glow-target-or-maximum-v1", + reason: "sound-bound-unavailable", + bounds: { kind: "unavailable" } +} +``` + +Для этой роли в `ResolvedTheme.vars` нет ключей `cssVar`, `${cssVar}-core` и +`${cssVar}-alpha`. `applyTheme` удаляет переменные, оставшиеся от предыдущего +determinate resolve; собственный DOM-adapter обязан обеспечить ту же очистку. + +`stable-v1` не означает «всегда Indeterminate». Если point screen-композит +bit-exact не может изменить фон при любой alpha, target/max-branch не требует +CAM16: результат determinate, `targetStatus: "exact-noop-unreachable"` и +`decisionGuarantee: { kind: "bit-exact" }`. + +### Determinate Glow + +По сравнению с 0.9.1 determinate `kind: "glow"` сохраняет прежние поля и +добавляет: + +| Поле | Смысл | +| --- | --- | +| `alphaCss` | Каноническая shortest-roundtrip строка той же binary64 alpha; CSS var копирует её буквально. | +| `constraintLayer` | Слой цели; сейчас `"halo"`. | +| `targetDj` | Запрошенный point `\|ΔJ′\|`. | +| `targetStatus` | `"exact-noop-unreachable"`, `"legacy-reached"` либо `"legacy-unreachable"`; provenance результата не теряется в общем boolean. | +| `haloCompositeHex` | Exact reference point-композит halo. | +| `haloAchievedDj` | Diagnostic `\|ΔJ′\|` этого halo-композита. | +| `coreCompositeHex` | Exact reference point-композит core при той же alpha. | +| `coreAchievedDj` | Diagnostic `\|ΔJ′\|` этого core-композита. | +| `compositeProfile` | `"encoded-srgb8-screen-v1"`. | +| `compositeGuarantee` | `"bit-exact"`. | +| `layerRecipeProfile` | `"cam16-jprime-oklab-cusp-v1"`; identity алгоритма построения core/halo, не numerical guarantee. | +| `appearanceDiagnosticProfile` | `"cam16-ucs-jprime-li2017-v1"`; non-null у полного resolved result, потому что `*AchievedDj` измеряются этой моделью. | +| `selectionDiagnosticProfile` | `"cam16-ucs-jprime-li2017-v1"` только когда модель участвовала в target/max selection; `null` у exact no-op. | +| `decisionProfile` | Профиль, явно выбранный конфигом. | +| `decisionGuarantee` | Tagged certificate semantic verdict: `{kind:"bit-exact"}` для exact no-op либо `{kind:"legacy-platform-dependent-v1"}` для compatibility path. Outward determinate Glow capability в этом релизе не поддерживается. | + +`achievedDj` остаётся deprecated alias на `haloAchievedDj`, а `degraded` — +deprecated alias на status `exact-noop-unreachable` или `legacy-unreachable`. +Новый код должен читать типизированные поля. + +В промежуточном pre-release контракте #269 девятым новым полем был единый +`referenceProfile`. В финальном 0.10.0 его нет: замените его семью отдельными +полями `compositeProfile`, `compositeGuarantee`, `layerRecipeProfile`, +`appearanceDiagnosticProfile`, `selectionDiagnosticProfile`, `decisionProfile`, +`decisionGuarantee`. Иначе exact-гарантия композитора ошибочно распространяется +на recipe, CAM16 diagnostics или semantic decision. + +## 3. Обновите Rust alpha API + +### Эмиссионный hex-resolver стал тотальным для валидного домена + +Было: + +```rust +let Some((tint, alpha)) = resolve_alpha_analog_hex(solid, requested, bg)? else { + return handle_absence(); +}; +``` + +Стало: + +```rust +let (tint, alpha) = resolve_alpha_analog_hex(solid, requested, bg)?; +``` + +Сигнатура теперь `Result<(String, f64), String>` вместо +`Result, String>`. Для валидных hex и конечной +`requested_alpha ∈ [0,1]` exact sRGB8-пара существует всегда; в худшем случае +фактическая alpha равна 1. Неконечная или внедиапазонная alpha возвращает +`Err`, а не клампится. + +Continuous `resolve_alpha_analog` сохраняет `Option`, потому что +его вход — произвольные числовые каналы; `None` означает недоменный ввод. Он +также больше не клампит недоменную requested alpha. + +### Прямые compositor-границы возвращают `Result` + +Обновите call sites для: + +- `composite_over_encoded(...) -> Result<[f64; 3], String>`; +- `screen_layer_over_encoded(...) -> Result<[f64; 3], String>`; +- `composite_over_srgb8(...) -> Result<[u8; 3], String>`; +- `screen_layer_over_srgb8(...) -> Result<[u8; 3], String>`. + +Публичный ввод с NaN, infinity или значением вне домена отклоняется одинаково +в debug и release. Не восстанавливайте прежнее поведение через локальный +`clamp`. + +### Material alpha сообщает typed outcome и численную границу честно + +`BackdropBox` больше нельзя создать struct literal: поля закрыты, а +`BackdropBox::try_new(min, max)` возвращает `BackdropBoxErrorV1` с отдельными +вариантами для reversed, non-finite и out-of-range channel. Границы не +переставляются и не клампятся. `worst_contrast_encoded`, +`solve_material_alpha_encoded` и `solve_material_alpha_hex` возвращают +`Result<_, MaterialSolveErrorV1>`; недостижимый floor остаётся typed domain +outcome `MaterialAlphaStatusV1::Degraded`, а не ошибкой или fallback. + +У `MaterialAlpha` используйте getters `alpha()`, `worst_contrast()`, `pole()`, +`status()` и `guarantee()`. Satisfied-результат несёт +`MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1`: после 60 шагов +бисекции `lower_alpha` повторно измерен ниже floor, `upper_alpha` повторно +проходит floor, а `alpha()` побитно равен `upper_alpha`. Перед поиском действует +directed-search guard; неподдерживаемое отношение возвращает +`UnsupportedDirectedSearchRelation`. Numerical profile явно равен +`encoded-srgb-byte-scale-affine-platform-binary64-powf-v1`. Это +platform-characterization, а не заявление глобальной монотонности, первого +passing state, точной минимальной alpha, предшественника или sound cross-runtime +bound. Degraded-результат аналогично +несёт `OpaqueEndpointCharacterizedV1` для повторно проверенного `alpha = 1`. +Если floor уже держится при `alpha = 0`, возвращается отдельный +`TransparentEndpointCharacterizedV1`, а не фиктивный threshold bracket. На wire +primary outcome — `alphaStatus`; прежний `guaranteed` остаётся только derived +compatibility alias. `alphaGuarantee` сохраняет tagged evidence и profile. + +Если consumer независимо перепроверяет material, он обязан повторить именно +byte-scale affine order `(B_byte + alpha·(T_byte−B_byte))/255`. Прежняя +normalized expanded запись `alpha·T + (1−alpha)·B` алгебраически равна, но не +принадлежит этому binary64 profile и расходится на известных WCAG-швах. +Профиль фиксирует original WCAG 2.1 (2018) split `0.03928` (не текущий W3C +`0.04045`). Для all-backdrop результата ядро расширяет фактические endpoint- +композиты conservative binary64 channel envelope и включает обе стороны +пересечённого EOTF seam; перепроверка только двух углов недостаточна. + +## 4. Обновите Rust Glow API + +`solve_screen_alpha_for_dj` теперь принимает обязательный +`GlowDecisionProfileV1` и возвращает `NumericalDecisionV1`: + +```rust +let decision = solve_screen_alpha_for_dj( + tint, + background, + target_dj, + GlowDecisionProfileV1::StableV1, + viewing_conditions, +)?; + +match decision { + NumericalDecisionV1::Determinate { value, guarantee } => { + emit(value.alpha_css(), guarantee); + } + NumericalDecisionV1::Indeterminate { site_id, evidence } => { + match evidence { + NumericalIndeterminacyV1::SoundBoundUnavailable => { + record_unbounded(site_id); + } + NumericalIndeterminacyV1::IntervalOverlap(interval) => { + record_overlap(site_id, interval.lower(), interval.upper()); + } + // `NumericalIndeterminacyV1` is `#[non_exhaustive]`. + _ => return handle_unknown_numerical_evidence(), + } + } + // `NumericalDecisionV1` is also `#[non_exhaustive]`. + _ => return handle_unknown_numerical_decision(), +} +``` + +`evidence` не позволяет собрать логически невозможную пару «reason отдельно, +bounds отдельно»: `SoundBoundUnavailable` не несёт интервал, а +`IntervalOverlap(interval)` обязан владеть валидированным outward interval. +WASM-проекция сохраняет discriminated wire-форму `reason` + `bounds`, но выводит +оба поля из одного enum-варианта. Внешний Rust-код обязан сохранять wildcard-arm +для `#[non_exhaustive]` enum и трактовать неизвестный вариант как явную +несовместимость версии, а не как legacy fallback. + +`DecisionGuaranteeV1` тоже `#[non_exhaustive]`. На WASM-границе он сериализуется +tagged object, а не строкой: + +```ts +type GlowDecisionGuaranteeV1 = + | { readonly kind: "bit-exact" } + | { readonly kind: "legacy-platform-dependent-v1" }; +``` + +Generic core сохраняет `DecisionGuaranteeV1::OutwardIntervalV1` для других +численных sites, но Glow-adapter не умеет построить соответствующий determinate +outcome и возвращает структурную несовместимость. Не принимайте неизвестный +`kind` как `bit-exact` или legacy. + +Low-level UniFFI/Swift поверхность не переносит flattened provenance-поля +generic wire-формы. Её `GlowPointDecision` — algebraic sum ровно четырёх +допустимых outcome: `stableExactNoop(value)`, `legacyReached(value)`, +`legacyUnreachable(value)` и `indeterminate(siteId, evidence)`. Общий +`GlowPointValue` хранит только alpha, target/achieved diagnostic, exact +composite hex и composite profile/guarantee. Отдельные native-типы +`GlowDecisionGuarantee`, `GlowTargetStatus`, `GlowDiagnosticProfile` и +одноимённые независимые output-поля удалены: невозможный cross-product нельзя +сконструировать. `GlowDecisionProfile` остаётся обязательным input функции, но +не дублируется в output. + +Native adapter заранее валидирует tint, background и конечный `targetDj > 0`; +только такой public input возвращает `ColorError.InvalidGlowRequest`. Если после +успешной проверки core всё же возвращает `Err`, неизвестный forward variant, +illegal provenance tuple либо generic `DecisionGuaranteeV1::OutwardIntervalV1`, +граница возвращает `ColorError.IncompatibleCoreContract`. Тот же закон действует +для нового неизвестного `Unreachable`: adapter не подменяет его строкой +`"unreachable"`. `NumericalIndeterminacy.intervalOverlap` остаётся в Swift как +законное outward evidence для `indeterminate`. + +В Swift замените прежний `case .determinate(...)` на exhaustive switch по этим +четырём вариантам; `value` одинаков только по форме composite payload, а смысл +ветки берётся из enum-case. Удалите mocks и helper-типы, которые продолжают +принимать независимые native decision/status/diagnostic поля. + +Поля `GlowSolve` закрыты; используйте getters `alpha()`, `alpha_css()`, +`target_dj()`, `achieved_dj()`, `composite_hex()`, `status()` и +`selection_diagnostic_profile()`, `composite_certificate()`. Exact certificate хранит tint/background bytes, +binary64 identity alpha, каноническую CSS-строку и composite bytes. Он не +сертифицирует браузер, дисплей, blur или пространственное перекрытие. + +## 5. Обновите fixtures, mocks и golden + +Минимальный список: + +1. Добавьте `decision_profile` во все Glow-рецепты JSON и typed builders. +2. Обновите exhaustive `RoleResult` switches для `glow-indeterminate`. +3. Добавьте все 15 determinate-полей из таблицы выше; удалите + `referenceProfile` из pre-release mocks. +4. Для `stable-v1` проверяйте отсутствие трёх CSS vars, а не snapshot legacy- + цвета. +5. Сравнивайте alpha через `alphaCss` или побитный parse round-trip; не + округляйте её до фиксированного числа знаков. +6. Используйте conformance pack 2.0.0. Half-tie + `#C0B2FA @ 0.122` над `#000000` обязан дать `#17161F`. Обрабатывайте + `generate_solve()` / `Pack::generate()` как `Result`: internal core failure + теперь возвращает `PackGenerationError`, а не fake `unreachable` vector. +7. Проверьте имена `Zero`-ролей и алиасов на них: их `cssVar` остаётся + client-owned namespace даже без эмитированного значения и не может совпадать + с производным ключом Glow (`-core`/`-alpha`) или Material (`-01`/`-02`). + Такой конфиг теперь честно отклоняется на preflight вместо зависимости от + порядка JSON-писателей. + +В закреплённом Lab UI regression corpus exact alpha меняет ровно 24 листа +`resolveVars`: четыре `--lab-fx-glow-*-alpha` на шести записях. Все 1376 пар +`(lc, wcag)` в секции recheck остаются байт-идентичны. Это характеристика +конкретного зафиксированного corpus, не обещание для произвольного клиентского +конфига. + +## Порядок rollout + +1. Подготовьте новый конфиг с explicit profile и сохраните snapshot старого. +2. Выпустите потребителя, который понимает оба Glow outcome и очищает stale + vars. +3. Обновите npm/Rust runtime и conformance pack согласованно. +4. Сбросьте кэш, привязанный к старому config fingerprint. Для полного Lab UI + паспорта ожидаемый переход — `f2a892a62f7bc91e` → `c51445fcd167781a`; + отклонение требует проверки фактического JSON, а не ручной подмены пина. +5. Сначала разверните `legacy-platform-dependent-v1`, если требуется + byte-compatible CSS-поведение; отдельно включите `stable-v1` и наблюдайте + typed indeterminacy по `numericalSiteId`/`reason`. + +## Rollback + +Rollback выполняется парой runtime + config: + +1. Зафиксируйте до rollout проверенные артефакты npm 0.9.1 / Rust 0.1.0, + lockfiles и конфиг, который принимала старая schema. +2. Верните старый runtime и старый config snapshot атомарно. Не рассчитывайте, + что старая версия обязана понимать новый `decision_profile`. +3. Очистите cache namespace нового fingerprint и выполните полный resolve/apply + для каждого активного root; не переиспользуйте `ResolvedTheme` другой + версии. +4. Проверьте восстановление halo/core/alpha vars и старого conformance pack до + открытия трафика. + +Данных пользователя эта миграция не преобразует. Опасность rollback — только +смешение несовместимых schema/result contracts и оставшиеся CSS vars. + +## Границы гарантии + +- `bit-exact` относится к объявленному encoded-sRGB8 point-композитору. +- `cam16-jprime-oklab-cusp-v1` — identity layer recipe, не numerical guarantee. +- `cam16-ucs-jprime-li2017-v1` — identity appearance/selection diagnostic, + не error bound; наличие в appearance measurement не доказывает selection. +- `legacy-platform-dependent-v1` явно допускает зависимость semantic branch от + platform/libm. +- `stable-v1` сохраняет неопределённость вместо выдуманного epsilon или + fallback. +- Ни один из этих профилей не сертифицирует реальный browser color-management, + HDR/display pipeline, blur, overlap или spatial glow field. diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 5ebb4990..af1d041a 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -37,7 +37,8 @@ ### 1.2. Выбор пространства -LCS построено поверх CIECAM16 с двумя отступлениями +LCS построено поверх текущей транскрипции CAM16 из Li et al. 2017 с двумя +отступлениями. Это не заявка на независимую CIE 248:2022 conformance ([`README.md`](../README.md), «LCS — Labpics Color Space»): **Яркость и цветность — из CAM16-UCS, не из «сырого» CIECAM16.** CIECAM16 даёт @@ -51,7 +52,8 @@ M' = ln(1 + 0.0228 × M) / 0.0228 Формулы — [`spaces/cam16.rs`](../crates/labcolors-core/src/spaces/cam16.rs) (`ucs_j` / `ucs_m`); применяет их [`lcs.rs`](../crates/labcolors-core/src/lcs.rs). -Следствие: `J' = 50` воспринимается как середина шкалы яркости. +`J' = 50` — числовая середина нормированной шкалы CAM16-UCS; это не отдельное +психофизическое утверждение о воспринимаемом midpoint. **Оттенок — из Oklab, не из CAM16.** Для интерполяции между цветами используется Oklab hue `h_ok` (Ottosson 2020, «A perceptual color space for image processing»; @@ -85,7 +87,8 @@ LCS учитывает окружение через параметры CIECAM16 `#787880` даёт `J' ≈ 53.5` в светлой теме и `J' ≈ 59.2` в тёмной. Каждая кривая хранит VC, которым была создана; смешение VC на roundtrip ловится тестом `wrong_vc_roundtrip_drifts`. В клиентском конфиге произвольных VC-чисел нет — -только закрытое меню калиброванных пресетов `VcPreset` +только закрытое меню номинальных пресетов `VcPreset`; они фиксируют параметры +модели, но не заявлены как отдельно откалиброванные на наблюдателях ([`config.rs`](../crates/labcolors-core/src/config.rs)). ### 1.4. Непрерывные кривые вместо ступеней @@ -294,7 +297,7 @@ Background-рампа, КАЖДАЯ ступень которой сидит н Детерминированный выбор авто-акцента по непрозрачному ключу потребителя строится поверх агностичного примитива-хеша ([`hash.rs`](../crates/labcolors-core/src/hash.rs), -#183): FNV-1a 32-бит (`offset_basis = 2166136261`, `prime = 16777619` — +`#183`): FNV-1a 32-бит (`offset_basis = 2166136261`, `prime = 16777619` — опубликованная спецификация Fowler–Noll–Vo). ~10 строк обёрточной целочисленной арифметики без таблиц делают JS-зеркало (`packages/colors/fnv1a.js`) тривиально верифицируемым: дифференциальный тест на якорных векторах `""`, `"a"`, `"foobar"` @@ -412,8 +415,9 @@ scale 1.14, low-clip и офсеты полярности версии 0.0.98G-4 ### 3.3. Двухслойные полы: перцепция и закон раздельно -Решатель целится в LPC-таргет, затем проверяет юридический минимум WCAG 2.1 AA -(4.5:1 текст, 3:1 UI; через EN 301 549 его требует European Accessibility Act); +Решатель целится в LPC-таргет, затем проверяет настроенный compatibility-floor +по legacy WCAG 2.x ratios (4.5:1 текст, 3:1 UI). Это технический safety gate, +а не юридическое заключение о применимости EN 301 549 или EAA к продукту; если закон строже перцепции — цвет досдвигается до пола с флагом `floor_override`. Оба числа (`lc`, `wcag_ratio`) отчитываются раздельно и не смешиваются. Декоративные контракты юридического пола не получают — @@ -490,13 +494,17 @@ Warning от сползания в красную (danger) зону. Полны - верификация мёртвой ручки `preferred_side` — прямым прогоном резолва (4 темы × 6 фонов × все роли): ноль сдвинутых байт ([`config.rs`](../crates/labcolors-core/src/config.rs)); -- WASM-обёртка гоняет headless-Chrome паритет-тест против нативного - `resolve_named_set`; наружная форма ролей едина — CSS-эмиссия oklch +- WASM-обёртка гоняет headless-Chrome паритет-тест JS-границы против core- + `resolve_named_set` внутри того же wasm runtime. Это проверка проекции и + контракта границы, не native↔WASM/libm доказательство (#223); наружная форма + ролей едина — CSS-эмиссия oklch ([`spaces/oklch.rs`](../crates/labcolors-core/src/spaces/oklch.rs); [`README.md`](../README.md), «Тесты»). -Кросс-валидация модели: `golden_tests` сверяют CIECAM16 против colour-science с -задокументированными допусками ([`README.md`](../README.md), «Тесты»). +`golden_tests` сверяют выбранные CAM16/CAM16-UCS преобразования с эталонными +значениями и задокументированными допусками. Это проверка конкретных числовых +путей, не сертификат полной CIECAM16-конформности +([`README.md`](../README.md), «Тесты»). ### 3.7. Материал: двухслойный контракт с ВЫВЕДЕННОЙ альфой (#89) @@ -514,33 +522,55 @@ GLASS-режиме база отброшена и `01` лежит над ЖИВ **Единственная решаемая величина — альфа**, и она НЕ рукописная. Солид-канон `01`-над-`02` = `α·T + (1−α)·T = T` — байт-точно равен тону при любой α (композит `T` над `T` есть `T`), поэтому солид тривиально верен; альфа выводится из -GLASS-режима. Тинт над фоном `B` даёт композит `α·T + (1−α)·B` (гамма-кодированный -sRGB — то же измеренное device-пространство, что у [`alpha.rs`](../crates/labcolors-core/src/alpha.rs)). +GLASS-режима. Тинт над фоном `B` считается в объявленном byte-scale affine +порядке binary64-операций +`(B_byte + α·(T_byte−B_byte))/255`. Ядро +[`material.rs`](../crates/labcolors-core/src/material.rs) и официальный +потребитель [`effective-bg.js`](../packages/colors/effective-bg.js) исполняют +этот порядок одинаково. Алгебраически эквивалентная expanded-запись не является +тем же численным профилем: на WCAG-швах она может выбрать соседнюю alpha. +Профиль заземлён измеренными Figma-парами, но не объявляет любой +browser/color-management pipeline тождественным. Контракт: композит над ЛЮБЫМ разрешённым фоном обязан держать читаемость поверхности. Поверхность коммитит **ахроматический полюс** максимального контраста на тоне (белый на тёмном `T`, чёрный на светлом — параметр-свободный механизм -полярности); α — минимальная плотность, при которой этот полюс держит WCAG-пол -`floor` (4.5 / 3.0) по всему коридору достижимых фонов `[чёрный, белый]` (стекло -над неизвестным фоном — худший коридор). - -Композит афинно-возрастает по каждому каналу фона, а WCAG-светлота монотонна по -каналам композита, поэтому светлота композита экстремальна на углах коридора -`min`/`max`; худший контраст полюса берётся по достижимой полосе -`[effLow, effHigh]` — инверсия α-граничной гарантии потребителя -(`material-guarantee.ts`): не «годен при данной α», а «минимальная годная α». -Худший контраст монотонно растёт по α (полоса стягивается к тону), поэтому солвер — -бисекция, как остальные полы движка (`glow::solve_screen_alpha_for_dj`). На α = 1 -полоса вырождается в `L(T)`, а полюс максимального контраста на ЛЮБОМ тоне даёт -≥ 4.58:1 (кроссовер чёрного и белого полюса при `L ≈ 0.179`), поэтому для пола ≤ AA -годная α ∈ (0, 1] существует всегда; при более высоком поле честно возвращается -α = 1 с флагом `guaranteed = false` — не молчание. +полярности). Солвер возвращает проходящий верхний кандидат 60-шаговой бинарной +partition для WCAG-пола `floor` (4.5 / 3.0) по всему коридору достижимых фонов +`[чёрный, белый]` (стекло над неизвестным фоном — худший коридор) и повторно +проверяет обе стороны найденного fail/pass bracket в объявленном runtime. + +Профиль намеренно заморожен на original WCAG 2.1 (2018) EOTF split `0.03928`; +текущий текст W3C использует `0.04045`, а его версионированная миграция +принадлежит issue #284. У legacy-функции есть малый downward seam, а binary64- +округление byte-scale affine композитора может опустить interior background ниже +обоих endpoint-значений. Поэтому ядро строит conservative поканальный envelope +фактических endpoint-вычислений, добавляет доказанный абсолютный запас для +compositor rounding, включает обе стороны пересечённого EOTF seam и только затем +складывает положительно взвешенные channel ranges. Это all-backdrop +characterization, а не двухугловая теорема. + +Fixed-step бисекция разрешена только после directed-search guard: отношение тона +и backdrop обязано стягивать допустимый интервал в нужном направлении полюса. +Полный коридор `[чёрный, белый]` удовлетворяет guard; неподдерживаемое узкое +отношение возвращает typed `UnsupportedDirectedSearchRelation`, а не ложную +alpha. Ни глобальная монотонность branch-sensitive predicate, ни первый passing +state, ни точная минимальная alpha не заявляются. WCAG-светлота содержит +platform/toolchain-sensitive `powf`, поэтому результат остаётся +platform-characterized и не является sound cross-runtime bound. На α = 1 +полоса вырождается в `L(T)`, а полюс максимального контраста на +ЛЮБОМ тоне даёт ≥ 4.58:1 (кроссовер чёрного и белого полюса при `L ≈ 0.179`), +поэтому для пола ≤ AA годная α ∈ (0, 1] существует всегда; при более высоком +поле честно возвращается typed `Degraded` с повторно проверенным opaque endpoint +`α = 1` — не молчание. Порядок тиров **выводится физикой, не подбором**: на светлой теме тон дальше от белого (крупнее |ΔJ'| = base) требует ПЛОТНЕЕ α, чем ближе к фону (subtle) — инвариант запинен тестами. Замер идёт на КВАНТОВАННОМ тоне-тинте (эмитируемый `01`) -с точным (непереквантованным) композитом над углами — ровно как потребитель -пересчитывает вердикт из эмитированных `01`/`02`, поэтому гарантия ядра и re-check -потребителя тождественны (тест `material_guarantee_recomputable_over_worst_backdrop`). +с непереквантованным reference-композитом и conservative channel/EOTF envelope +— ровно в byte-scale affine order официального потребителя. Выбранный upper candidate +повторно проходит floor в том же охарактеризованном numerical profile (тест +`material_guarantee_recomputable_over_worst_backdrop`); это re-check выбранного +state, не proof его точной минимальности или cross-runtime bit identity. --- @@ -592,7 +622,7 @@ sRGB — то же измеренное device-пространство, что | `NEUTRAL_TINT_RATIO` | 0.10 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | ручка нейтрального подтона; терминал **(e)** design-choice (opt-in v1-политика; sensitivity max ΔE_ok 0.0288 по [0,0.2], Волна 2) | | `TINT_TARGET_MP` | 6.1 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | целевая перцептивная красочность подтона | | `TINT_HUE_STIFFNESS` | 9.0 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | жёсткость притяжения оттенка подтона; терминал **(c) interval-insensitive** (Волна 2): выше порога пиннинга ≈0.36 выход байт-инвариантен по [1,100], дефолт 9.0 = 25× порога | -| `gamma_light` / `gamma_dark` | 1.75 / 1.5 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) (`CurveParams::default`) | дефолты степенной интерполяции; **РЕОТКРЫТ (#95, 2026-07-08): «закон не найден ЕЩЁ»** — три метрики step-uniformity рефутируют скаляр (Oklab 1.07, sRGB 1.10, surround-CAM16-J' 0.99–1.04; направление право, магнитуда мала), активная гипотеза — crispening-метрика Уиттла (empirical-inventory §co-dependency) | +| `gamma_light` / `gamma_dark` | 1.75 / 1.5 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) (`CurveParams::default`) | терминал **(e) COMPATIBILITY POLICY** для замороженной прежней эмиссии; **НАУЧНАЯ ЗАМЕНА: OPEN (#219/#261)** — четыре скалярных объяснения опровергнуты по магнитуде; это не human law и не блокер generic fact-only construction | | `chroma_peak_t` | 0.35 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) (`CurveParams::default`) | позиция пика огибающей хромы; **КОНТЕКСТ-ФУНКЦИЯ (hue) ПОДТВЕРЖДЕНА (#95)** — `f(hue) =` cusp-светлота, диапазон [0.225,0.479] по акцентам, среднее 0.379 ≈ 0.35; эмиссионно-нейтрален (кандидат в байт-идентичную объективизацию) | | доли текста | 0.97335917 / 0.64359014 / 0.47572199 / 0.29335999 | пресет labui | Ys-перенос Figma-якорей (генезис Y_hk: 102.6 / 66.5 / 48.9 / 29.3; инвариант — цвет: Ys принятых `#141414`/`#767676`/`#C2C2C2` ÷ 106.0407, tertiary — инверсия 48.9 → `#9C9C9C`); деривация запечатана гейтом `anchor_transfer_derivation`; «калибруется» до визуальной приёмки ([`README.md`](../README.md)) | @@ -634,15 +664,17 @@ sRGB — то же измеренное device-пространство, что психофизическую величину, если её статус в инвентаре — калибровка, design-choice или черновиковый заземлённый уровень. -**Созависимые константы: design-choice — не терминал (парадигма владельца, 2026-07-08).** -Для константы, значение которой co-зависит от контекста (фон темы, оттенок), рефут -СКАЛЯРНОЙ деривации НЕ закрывает вопрос в терминал (e): такая константа — кандидат в -ФУНКЦИЮ КОНТЕКСТА, и design-choice допустим лишь после исчерпания контекстных гипотез. +**Созависимые константы: научный статус отделён от compatibility provenance (#281).** +Для константы, значение которой co-зависит от контекста, рефут скалярной +деривации открывает кандидат функции контекста, но не стирает терминальный +провенанс уже отгруженного значения. Gamma остаётся (e) compatibility policy, +а её возможная человеко-ориентированная замена — отдельный OPEN-вопрос. Прецедент рефута скалярного кандидата — `HUE_DRIFT_PENALTY_SLOPE` (хорда Oklab отклонена замером) — валиден для НЕ-созависимых ручек; у `CurveParams` контекст есть. -Замеры #95: `gamma_light/dark` — три метрики step-uniformity рефутируют скаляр -(surround-aware CAM16-J' даёт верное НАПРАВЛЕНИЕ асимметрии, но малую магнитуду vs -1.75/1.5; активная гипотеза — crispening-метрика Уиттла); `chroma_peak_t` — hue-функция +Замеры #95/#108: `gamma_light/dark` — четыре скалярные метрики рефутированы по +магнитуде (CAM16 J′ с окружением даёт верное НАПРАВЛЕНИЕ асимметрии, но малую +магнитуду относительно 1.75/1.5; прямое прочтение метрики Уиттла вырождается); +корневая контекстная модель остаётся открытой в #219/#261. `chroma_peak_t` — hue-функция подтверждена (cusp-светлота, среднее по акцентам 0.379 ≈ 0.35), эмиссионно-нейтральна. Полный разбор с числами — `docs/empirical-inventory.md` (§co-dependency). @@ -693,11 +725,13 @@ sRGB — то же измеренное device-пространство, что ([`config.rs`](../crates/labcolors-core/src/config.rs)). - **Не библиотека с деревом зависимостей.** `labcolors-core` — zero-dep (issue #29). -- **Не меню произвольных условий просмотра.** VC — закрытый набор калиброванных - пресетов `VcPreset` ([`config.rs`](../crates/labcolors-core/src/config.rs)). +- **Не меню произвольных условий просмотра.** VC — закрытый набор номинальных + пресетов `VcPreset`; отдельная observer-calibration не заявлена + ([`config.rs`](../crates/labcolors-core/src/config.rs)). - **Не оверрайд-система пресетов (пока).** Пресет BL-007 — всё-или-ничего; оверрайд отдельных ролей поверх пресета — будущий слой, если владелец решит ([`config.rs`](../crates/labcolors-core/src/config.rs)). - **Не универсальная модель цветовосприятия.** LCS — прикладной гибрид - (CAM16-UCS × Oklab hue) под задачу генерации UI-палитр; претензий на новую - CAM у системы нет. + (CAM16-UCS × Oklab hue) внутри contextual compiler/runtime-resolver клиентских + color-token contracts; претензий на новую CAM или palette-generator semantics + у системы нет. diff --git a/experiments/psychophysics/Cargo.toml b/experiments/psychophysics/Cargo.toml index 42651450..65a06e47 100644 --- a/experiments/psychophysics/Cargo.toml +++ b/experiments/psychophysics/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true # Zero runtime dependencies — по конституции репо (issue #29). Генератор стимулов, # логистическая MLE, bootstrap и минимальный JSON написаны руками на `std`, поэтому diff --git a/experiments/psychophysics/src/stimulus.rs b/experiments/psychophysics/src/stimulus.rs index 82154aff..34d692fb 100644 --- a/experiments/psychophysics/src/stimulus.rs +++ b/experiments/psychophysics/src/stimulus.rs @@ -297,10 +297,10 @@ impl Manifest { pub fn families_or_fallback() -> Vec { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); let path = format!("{root}{}", crate::passport::default_passport_relpath()); - if let Ok(text) = std::fs::read_to_string(&path) - && let Ok(f) = crate::passport::families_from_passport(&text) - { - return f; + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(families) = crate::passport::families_from_passport(&text) { + return families; + } } ["#FF3B30", "#FFA100", "#34C759", "#007AFF"] .iter() diff --git a/packages/colors/README.md b/packages/colors/README.md index 34868659..77e9759c 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -20,6 +20,14 @@ npm install @labpics/colors npm run build # → pkg/ (wasm + JS-обёртка + .d.ts) ``` +Пакет экспортирует `@labpics/colors/build-metadata.json` — self-declared +machine-readable metadata конкретной сборки: npm/core versions, exact source +SHA, digest и SHA-256 conformance manifest/family set, а также размер и SHA-256 +WASM-байтов. Release-gate сверяет объект целиком с исходными файлами и повторяет +проверку после чистой установки tarball. Это integrity metadata внутри +артефакта, не криптографическая provenance/Sigstore-аттестация, не runtime +telemetry и не сетевой запрос. + --- ## Как использовать @@ -137,13 +145,51 @@ interface ResolvedTheme { roles: Record; // все роли с деталями } -type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole; +type RoleResult = + | SolvedColor + | TranslucentRole + | GlowRole + | MaterialRole + | NoneRole + | UnreachableRole; ``` -Каждая роль — одно из четырёх состояний: +Каждая роль — одна из шести категорий результата; у Glow есть два терминальных +состояния: - `SolvedColor` — цвет найден (`kind: "color"`, поля `css` — готовое `oklch(L% C H)`, `hex` — тот же цвет как данные, `lc`, `wcagRatio`, …). -- `TranslucentRole` — полупрозрачная роль лестницы или альфа-аналога (`kind: "translucent"`): `css` — готовое `oklch(L% C H / A)`, `tintHex` — тинт как данные, `alpha`, плюс `compositeHex` / `compositeLc` / `compositeWcag` — солид-композит на фоне резолва и его контраст (браузер композитит тинт на живой подложке). +- `TranslucentRole` — полупрозрачная роль лестницы или альфа-аналога (`kind: "translucent"`): `css` — готовое `oklch(L% C H / A)`, `tintHex` — тинт как данные, `alpha`, плюс `compositeHex` / `compositeLc` / `compositeWcag` — exact encoded-sRGB8 reference-композит на фоне резолва и его контраст. Конкретный renderer и color-management pipeline проверяются отдельно. +- `GlowRole` — discriminated union. `kind: "glow"` несёт два цвета для + `mix-blend-mode: screen`: `cssVar` — halo, `${cssVar}-core` — core, + `${cssVar}-alpha` — каноническая `alphaCss`. `compositeProfile` / + `compositeGuarantee` описывают exact point-композит отдельно от + `decisionGuarantee`. `layerRecipeProfile` фиксирует общий рецепт слоёв + `"cam16-jprime-oklab-cusp-v1"`, а обязательный + `appearanceDiagnosticProfile` — CAM16 appearance-модель результата. Отдельный + `selectionDiagnosticProfile` сообщает, применялась ли CAM16-диагностика именно + при выборе состояния: для exact no-op он равен `null`, для legacy solve — + `"cam16-ucs-jprime-li2017-v1"`. `targetStatus` различает эти ветви явно: + `"exact-noop-unreachable"`, `"legacy-reached"` или + `"legacy-unreachable"`. Determinate-форма — union из этих трёх согласованных + ветвей: смешать stable/legacy profile, guarantee, selection diagnostic, + status и `degraded` на уровне TypeScript невозможно. Outward interval не + входит в Glow capability этого релиза. `kind: "glow-indeterminate"` + означает, что профиль `stable-v1` не выбрал target/max state без sound bound; + для такой роли CSS-переменные не эмитятся и legacy fallback не применяется. + Цель и все `*CompositeHex` / `*AchievedDj` относятся только к изолированным + point-слоям, а не к полному blur/overlap-эффекту, браузеру или дисплею. +- `MaterialRole` — union трёх терминальных исходов: satisfied transparent + endpoint (`alpha = 0`), satisfied bisection bracket (`alpha` побитно равна + `upperAlpha`) и degraded opaque endpoint (`alpha = 1`). `alphaStatus`, + `alphaGuarantee` и compatibility-поле `guaranteed` согласованы типом. `floor` + — запрошенный пол; он держится только при `alphaStatus: "satisfied"`. Primary + остаётся солид-каноном, `-01` — тинтом, `-02` — опаковой базой. Численный + профиль явно фиксирует operation order: + `encoded-srgb-byte-scale-affine-platform-binary64-powf-v1`: original WCAG 2.1 + (2018) split `0.03928`, conservative binary64 channel envelope и обе стороны + пересечённого EOTF seam. Bracket доступен только после directed-search guard; + это platform-characterization, а не двухугловая теорема, глобальная + монотонность, первый passing state или точная минимальная alpha. - `NoneRole` — роль намеренно пустая по дизайну (`kind: "none"`), не ошибка. - `UnreachableRole` — ни один цвет не удовлетворяет требованиям для этого фона (`kind: "unreachable"`). @@ -159,7 +205,26 @@ type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole; ### `engine.loadConfig(json): string` -Загружает конфиг дизайн-системы (JSON по типу `ThemeConfig`; схема — в репозитории: `docs/decisions/0001-config-boundary.md`, TS-типы — в поставляемом `labcolors.d.ts`). Это **единственный** источник словаря ролей — встроенной таблицы в движке нет: до загрузки конфига `resolveTheme` отклоняется ошибкой `config_required`. Полный preflight: невалидный конфиг отклоняется структурной ошибкой `invalid_config: …` и НЕ меняет состояние. Возвращает отпечаток конфига — 16 hex-символов; разные конфиги дают разные отпечатки и разные кэш-пространства. +Загружает конфиг дизайн-системы (JSON по типу `ThemeConfig`; схема — в репозитории: `docs/decisions/0001-config-boundary.md`, TS-типы — в поставляемом `labcolors.d.ts`). Это **единственный** источник словаря ролей — встроенной таблицы в движке нет: до загрузки конфига `resolveTheme` отклоняется ошибкой `config_required`. Полный preflight проверяет не только имена ролей, но и итоговый CSS-namespace: `-core`/`-alpha` Glow и `-01`/`-02` Material не могут быть затёрты другой ролью или алиасом. Невалидный конфиг отклоняется структурной ошибкой `invalid_config: …` и НЕ меняет состояние. Возвращает отпечаток конфига — 16 hex-символов; разные конфиги дают разные отпечатки и разные кэш-пространства. + +Каждый Glow-рецепт обязан явно выбрать numerical-decision profile; default и +неявного legacy-пути нет: + +```json +{ + "kind": "glow", + "source": { "kind": "brand" }, + "step": "base", + "decision_profile": "stable-v1" +} +``` + +`stable-v1` возвращает typed `glow-indeterminate`, когда sound bound для +нетривиального CAM16 target/max-решения недоступен. Если существующая интеграция +обязана пока сохранить прежнюю CSS-эмиссию, укажите +`legacy-platform-dependent-v1` явно и обрабатывайте его как compatibility +profile, а не как численную гарантию. Полная миграция с rollback-порядком — в +[гайде exact alpha / typed Glow](https://github.com/Labpics-Team/lab-colors/blob/main/docs/migrations/exact-alpha-glow.md). --- @@ -175,9 +240,13 @@ type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole; --- -### `engine.muddiness(hex): number` · `engine.confidence(hex): number` +### `engine.muddiness(hex): number` -`muddiness` — оценка «грязи» цвета `hex` в диапазоне `[0, 1]` (Закон Грязи). `confidence` — надёжность этой оценки: `0` означает, что оценке нельзя доверять (у границы решения или серого фронтира), выше — увереннее. Верхний потолок — деталь калибровки, не контракт: не хардкодить. +`muddiness` возвращает замороженный числовой выход исторической формулы. Это +`experimental compatibility proxy`: legacy-имя сохранено для совместимости, но +значение не является валидированным на наблюдателях человеческим вердиктом +clean/dirty и не должно использоваться как production decision. Сам диапазон +`[0, 1]` — свойство формулы, а не шкала человеческого восприятия. --- @@ -216,7 +285,7 @@ interface WatchController { ### `adaptTheme(element, options): AdaptController` -Режим с гистерезисом: дешёвая проверка каждый кадр, пересчёт и плавный переход только при устойчивом ухудшении контраста. Применяет немедленно и возвращает контроллер. +Режим с dwell-фильтром: дешёвая проверка каждый кадр, пересчёт и плавный переход только при устойчивом ухудшении контраста. Это один порог с выдержкой, не двухпороговый гистерезис. Применяет немедленно и возвращает контроллер. ```ts interface AdaptThemeOptions { @@ -230,7 +299,7 @@ interface AdaptThemeOptions { sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120) dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250) easeMs?: number; // длительность перехода (по умолчанию 280; уменьшается при reduced-motion) - strict?: boolean; // держать минимальный контраст на каждом кадре перехода (по умолчанию false) + strict?: boolean; // legacy characterized clamp; не universal floor certificate (по умолчанию false) reducedMotion?: boolean; // переопределить системную настройку } @@ -243,20 +312,37 @@ interface AdaptController { } ``` +`strict` сохраняет прежнее runtime-поведение, но не является доказательством +минимального или проходящего состояния на каждом кадре: путь +Oklab→gamut clip→sRGB8 немонотонен. Finite hard-floor-safe replacement ведётся в +#287; до него этот флаг следует считать compatibility characterization. + Управляйте через `start()` (внутренний rAF-цикл) или вызывайте `tick()` из собственного цикла. Смена темы применяется мгновенно — это осознанное намерение, а не дрейф. +Перед применением результата контроллер проверяет provenance как stable-, так и +legacy-ветвей Glow. Нетипизированный внешний mock с невозможным сочетанием +profile/guarantee/diagnostic/status отклоняется `TypeError`; fallback-цвет из +такого объекта не применяется. --- ### `effectiveBackground(element, options?): string` -Возвращает непрозрачный `#RRGGBB` — цвет, который наблюдатель реально видит за содержимым `element`. Обходит цепочку предков и выполняет альфа-композитинг каждого `background-color` до получения непрозрачного результата, поверх `fallback` (по умолчанию белый). +Возвращает непрозрачный reference-`#RRGGBB`, вычисленный для поддерживаемого +подмножества DOM `background-color`. Это не browser pixel capture и не +сертификат цвета, который реально видит наблюдатель. Helper обходит цепочку +предков и композитит распознанные слои поверх `fallback` (по умолчанию белый). ```ts const bg = effectiveBackground(panel); // например "#0F1014" const bg2 = effectiveBackground(panel, { fallback: "#101012" }); ``` -**Честное ограничение:** работает только с сплошными и полупрозрачными `background-color` — не с `background-image`, градиентами, размытым фоном или видео. Для таких поверхностей передайте фон явно. +**Честное ограничение:** работает только с поддерживаемыми сплошными и +полупрозрачными `background-color`; неподдерживаемый CSS, неполная прозрачная +цепочка, `background-image`, градиент, blur и video не дают полного наблюдения. +Текущий compatibility helper ещё может отбросить неподдерживаемый слой или +использовать fallback; для correctness передайте фон явно. Typed `Unknown` +replacement принадлежит #283. Дополнительно экспортируются вспомогательные функции для работы со слоями: `parseCssColor`, `compositeOver`, `compositeStackToHex`, `toHex` и `oklabLerp` (перцептуально равномерная интерполяция между двумя hex-значениями — траектория цвета при переходе). @@ -264,19 +350,32 @@ const bg2 = effectiveBackground(panel, { fallback: "#101012" }); ## Размер бандла -| Артефакт | raw | gzip | -|----------|-----|------| -| `labcolors_bg.wasm` | ~313 КБ | ~138 КБ | -| `labcolors.js` (JS-обёртка) | ~17 КБ | ~4 КБ | - -Замер CI-шага `report bundle size` (ci.yml) — raw и gzip. - -Это ВЕСЬ движок: перцептивная модель CAM16, солверы контраста, лестницы, граница конфига. `.wasm` — не JS-байты бандла, а ассет: он **не на критическом пути рендера** — грузится параллельно, компилируется потоково вне главного треда, кэшируется браузером после первой загрузки. Вспомогательные функции (`applyTheme`, `watchTheme`, `adaptTheme`, `effectiveBackground`) — несколько сотен байт чистого JavaScript с tree-shaking через именованные экспорты. - -Требует современных браузеров (2023+): сборщики Vite / webpack 5 / Next штатно понимают `new URL('….wasm', import.meta.url)` (вывод wasm-pack). В node передавайте wasm-байты напрямую в `init({ module_or_path })`. +Размер не закреплён в документации приблизительным числом: оно устаревает при +любом изменении солвера. SSOT — шаг CI `report bundle size (gzip)` в `ci.yml`, +который для каждого коммита печатает точные raw-байты и результат `gzip -9` +отдельно для `labcolors_bg.wasm` и wasm-bindgen-обёртки `labcolors.js`. + +Это весь движок: CAM16, солверы контраста, лестницы и граница конфига. `.wasm` +поставляется отдельным ассетом. Будет ли его загрузка критическим путём первого +рендера, определяет интеграция: до первого `resolveTheme` инициализация обязана +завершиться; приложение может preload/кэшировать модуль. JS-хелперы +(`applyTheme`, `watchTheme`, `adaptTheme`, `effectiveBackground`) имеют +именованные экспорты и допускают tree-shaking, но их размер также следует мерить +сборкой, а не описывать приблизительно. + +Release packer закреплён на Node 24.14/npm 11.9; это не consumer floor. +Публичный контракт `Node >=22.11.0` независимо прогоняется отдельным CI job. Browser- +матрица использует прямую передачу wasm-байтов в `init({ module_or_path })` и +headless Chrome из CI. ESM/URL-обёртка генерируется wasm-pack, но совместимость +с конкретной версией Vite, webpack, Next или другим браузером не заявляется без +отдельного smoke-гейта. ### Для аудиторов цепочки поставки +- **Build metadata:** экспорт `@labpics/colors/build-metadata.json` декларирует + source SHA и conformance pack для установленного WASM; verifier отклоняет + любое лишнее, отсутствующее или несовпадающее поле. Объект не подписан и не + заменяет отключённую npm/Sigstore provenance-аттестацию. - **Network access (Socket и др.):** единственный `fetch` в пакете (`pkg/labcolors.js`) загружает СОБСТВЕННЫЙ `.wasm`-файл пакета при `init(url)` — стандартный лоадер wasm-bindgen. Ни внешних адресов, ни отправки данных, ни исполнения при импорте. В node-пути (передача байтов) `fetch` не вызывается. -- **Bundlephobia `BuildError`:** их webpack-конвейер не умеет `.wasm`-ассеты («loader customization needed») — так падает почти любой WASM-пакет. Реальные размеры — в таблице выше (замер CI-шага `report bundle size`). -- **Zero runtime JS-dependencies:** npm-поле `dependencies` пусто — транзитивной JS/npm-цепочки поставки нет. Rust-крейты сборки (`serde`, `serde_json` и др.) компилируются ВНУТРЬ `.wasm` (учтены в его размере выше); их цепочка аудируется на стороне сборки — `cargo audit` (RustSec) в CI lab-colors. +- **Bundlephobia `BuildError`:** их webpack-конвейер не умеет `.wasm`-ассеты («loader customization needed») — так падает почти любой WASM-пакет. Реальный размер конкретного коммита показывает CI-шаг `report bundle size (gzip)`. +- **Zero runtime JS-dependencies:** npm-поле `dependencies` пусто — транзитивной JS/npm-цепочки поставки нет. Rust-крейты сборки (`serde`, `serde_json` и др.) компилируются ВНУТРЬ `.wasm` (учтены CI-замером); их цепочка аудируется на стороне сборки — `cargo audit` (RustSec) в CI lab-colors. diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index 4a11d30f..35db47b0 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -4,14 +4,16 @@ import type { LabColors, ThemeName } from "./pkg/labcolors.js"; export interface AdaptThemeOptions { /** - * An initialised engine — needs `resolveTheme` AND `recheckContrast`. + * 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 present, a multi-sample backdrop * is rechecked in ONE batched call per frame (byte-identical to the * per-sample loop, locked by the wasm boundary parity test); when absent, * the controller falls back to N `recheckContrast` calls. */ colors: Pick & - Partial>; + Partial>; theme: ThemeName; /** * Explicit effective background, overriding the ancestor-composite. A single @@ -34,10 +36,10 @@ export interface AdaptThemeOptions { /** Crossfade duration in ms. Default `280` (capped to a short fade under reduced motion). */ easeMs?: number; /** - * Hold each role's WCAG legal floor every frame of the ease: an interpolated - * colour is only shown while it still clears the role's `legalFloor` against - * the live background (for text on animated content / `prefers-contrast`). - * Default `false` (the free ease — brief surplus dips, destination always legal). + * 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 certificate; #287 owns the finite replacement. + * Default `false`. */ strict?: boolean; /** Override reduced-motion detection (default reads `matchMedia`). */ diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index 34de4602..eeb8a8a3 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -39,13 +39,11 @@ // usually sit on a substrate, so a brief dip of the aesthetic *surplus* // during the ease is imperceptible while the freshly-solved destination is // always legal. -// * Strict (`strict: true`): the WCAG legal floor is HELD every frame. For -// text directly on animated content or under `prefers-contrast`, an -// intermediate colour is only shown while it still clears the role's -// `legalFloor` against the live background; a role whose eased intermediate -// would dip below its floor is advanced (monotonically) to the least blend -// that stays legal — never below the line, never a backwards flicker. Roles -// with no legal floor (decorative) ease freely either way. +// * Strict (`strict: true`): requests the legacy per-frame floor clamp. The +// current Oklab→clip→sRGB8 path is characterized, but is not globally +// monotone and therefore does not prove a least/legal blend for every frame; +// #287 owns the finite hard-floor-safe replacement. Roles with no declared +// floor (decorative) ease freely either way. import { applyTheme } from "./apply-theme.js"; import { @@ -67,9 +65,9 @@ function easeOut(t) { return 1 - u * u * u; } -/** WCAG 2.1 relative luminance of `#RRGGBB` — a faithful transcription of the - * normative definition (0.03928 split, 2.4 exponent), so the strict floor-clamp - * agrees byte-for-byte with the core's `legalFloor` semantics. */ +/** Relative luminance of `#RRGGBB` in the frozen original WCAG 2.1 (2018) + * profile (0.03928 split, 2.4 exponent), so the strict floor-clamp agrees + * byte-for-byte with the core's versioned `legalFloor` semantics. */ function relativeLuminanceHex(hex) { const rgb = parseCssColor(hex) ?? [0, 0, 0, 1]; const lin = (c) => { @@ -123,7 +121,7 @@ function segLum(seg, t) { * * @param {*} element * @param {object} options - * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:string,fgs:string[],theme:string)=>ArrayLike }} options.colors + * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:string,fgs:string[],theme:string)=>ArrayLike, isStableGlowPointNoop?:(tint:string,bg:string)=>boolean }} options.colors * @param {string} options.theme * @param {string | string[] | (() => string | string[])} [options.background] * explicit effective background. An ARRAY (or a function returning one) is a @@ -136,8 +134,9 @@ function segLum(seg, t) { * @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.strict=false] hold each role's WCAG legal floor - * every frame of the ease (for text on animated content / `prefers-contrast`) + * @param {boolean} [options.strict=false] enable the legacy characterized + * per-frame clamp; the current non-monotone interpolation path is not a + * universal floor certificate (replacement tracked by #287) * @param {boolean} [options.reducedMotion] override; default reads matchMedia * @param {() => number} [options.now] clock (default performance.now/Date.now) * @param {*} [options.win=globalThis] @@ -171,6 +170,11 @@ export function adaptTheme(element, options) { let theme = options.theme; /** @type {{ cssVar: string, key: string, lc: number, hex: string, legalFloor: number|null }[]} stable role order */ let roles = []; + /** Stable Glow roles need an exact class recheck in addition to color + * contrast rechecks. The only determinate stable state is the core-certified + * quantised screen point no-op; every other state is typed Indeterminate and must have + * no halo/core/alpha vars. */ + let stableGlows = []; /** Full canonical var set from the last solve — the `result.vars` of EVERY * reachable role (color AND translucent), each an oklch string. Every apply is * `applyTheme(target, {...baseVars, ...easedColorOverlay})`, so translucent @@ -254,9 +258,109 @@ export function adaptTheme(element, options) { return { breached, worstIdx }; }; - // Resolve a fresh set and adopt it as the current colours (no ease). - const solveAndAdopt = (bg, now) => { - const result = colors.resolveTheme(bg, theme); + const stableVarKeys = (role) => [ + role.cssVar, + `${role.cssVar}-core`, + `${role.cssVar}-alpha`, + ]; + + const hasDeterminateGlowEnvelope = (result, role, emittedKeys) => + role.compositeProfile === "encoded-srgb8-screen-v1" && + role.compositeGuarantee === "bit-exact" && + role.layerRecipeProfile === "cam16-jprime-oklab-cusp-v1" && + role.appearanceDiagnosticProfile === "cam16-ucs-jprime-li2017-v1" && + role.constraintLayer === "halo" && + typeof role.coreHex === "string" && + typeof role.haloHex === "string" && + emittedKeys.every((emittedKey) => typeof result.vars?.[emittedKey] === "string"); + + const validatedStableGlowsFrom = (result) => { + const out = []; + for (const [key, role] of Object.entries(result.roles ?? {})) { + if (!role) continue; + const isGlow = role.kind === "glow" || role.kind === "glow-indeterminate"; + if (!isGlow) { + if (role.decisionProfile !== undefined) { + throw new TypeError(`adaptTheme: decision profile on non-Glow role '${key}'`); + } + continue; + } + const expectedCssVar = `--lab-${key}`; + if (role.cssVar !== expectedCssVar) { + throw new TypeError( + `adaptTheme: Glow '${key}' has non-canonical cssVar`, + ); + } + const emittedKeys = stableVarKeys(role); + if (role.decisionProfile === "legacy-platform-dependent-v1") { + if (role.kind !== "glow") { + throw new TypeError( + `adaptTheme: legacy Glow '${key}' cannot be Indeterminate`, + ); + } + const reached = + role.targetStatus === "legacy-reached" && role.degraded === false; + const unreachable = + role.targetStatus === "legacy-unreachable" && role.degraded === true; + if ( + !hasDeterminateGlowEnvelope(result, role, emittedKeys) || + role.decisionGuarantee?.kind !== "legacy-platform-dependent-v1" || + role.selectionDiagnosticProfile !== "cam16-ucs-jprime-li2017-v1" || + (!reached && !unreachable) + ) { + throw new TypeError( + `adaptTheme: legacy Glow '${key}' lacks lawful legacy evidence`, + ); + } + continue; + } + if (role.decisionProfile !== "stable-v1") { + throw new TypeError(`adaptTheme: Glow '${key}' lacks an explicit known decisionProfile`); + } + if (role.kind === "glow") { + if ( + role.decisionGuarantee?.kind !== "bit-exact" || + !hasDeterminateGlowEnvelope(result, role, emittedKeys) || + role.selectionDiagnosticProfile !== null || + role.targetStatus !== "exact-noop-unreachable" || + role.degraded !== true + ) { + throw new TypeError(`adaptTheme: stable Glow '${key}' lacks BitExact evidence`); + } + out.push({ key, cssVar: role.cssVar, sourceHex: role.haloHex, indeterminate: false }); + continue; + } + if (role.kind === "glow-indeterminate") { + const expectedSite = + role.numericalSiteId === "glow-target-or-maximum-v1" && + role.constraintLayer === "halo"; + const unavailable = + role.reason === "sound-bound-unavailable" && role.bounds?.kind === "unavailable"; + const soundOverlap = + role.reason === "interval-overlap" && + role.bounds?.kind === "outward" && + Number.isFinite(role.bounds.lower) && + Number.isFinite(role.bounds.upper) && + role.bounds.lower <= role.bounds.upper; + const lawfulEvidence = expectedSite && (unavailable || soundOverlap); + if ( + !lawfulEvidence || + typeof role.sourceHex !== "string" || + emittedKeys.some((emittedKey) => Object.hasOwn(result.vars ?? {}, emittedKey)) + ) { + throw new TypeError(`adaptTheme: stable Glow '${key}' lacks lawful Indeterminate evidence`); + } + out.push({ key, cssVar: role.cssVar, sourceHex: role.sourceHex, indeterminate: true }); + continue; + } + throw new TypeError(`adaptTheme: stable decision profile on unknown Glow role '${key}'`); + } + return out; + }; + + // Adopt one already-resolved set as the current colours (no ease). + const adoptResolved = (result, now) => { + const nextStableGlows = validatedStableGlowsFrom(result); // Carry the FULL canonical var set (color + translucent) so no reachable // role is dropped by a subsequent apply; only color roles feed the ease. baseVars = result.vars && typeof result.vars === "object" ? result.vars : {}; @@ -269,6 +373,7 @@ export function adaptTheme(element, options) { hex: r.hex, legalFloor: typeof r.legalFloor === "number" ? r.legalFloor : null, })); + stableGlows = nextStableGlows; fgsCache = roles.map((r) => r.hex); // The adopt may change the var/role KEY SET — force the next write through // `applyTheme`'s full clear-then-write instead of the mid-ease diff path. @@ -278,6 +383,9 @@ export function adaptTheme(element, options) { return result; }; + // Resolve a fresh set and adopt it as the current colours (no ease). + const solveAndAdopt = (bg, now) => adoptResolved(colors.resolveTheme(bg, theme), now); + // Solve+adopt against the hardest of `samples`. With one sample this is a // single solve; with several it does a provisional solve to learn the role // colours, picks the worst sample for them, and re-solves against it if that @@ -328,6 +436,91 @@ export function adaptTheme(element, options) { // oklch form, translucent roles their tint+alpha. const applyRolesDirect = () => applyHexes({}); + /** + * Recheck the background-dependent stable Glow decision class. This is not a + * contrast surplus and therefore never passes through sustain/dwell/easing. + * A stable Glow result requires the core-owned exact predicate. Missing + * capability is a typed integration error, never a hidden full-solve loop. + */ + const reconcileStableGlows = (samples) => { + if (stableGlows.length === 0) return false; + + if (typeof colors.isStableGlowPointNoop !== "function") { + throw new TypeError( + "adaptTheme: stable Glow requires colors.isStableGlowPointNoop", + ); + } + const desired = new Map(); + for (const role of stableGlows) { + desired.set( + role.key, + samples.some((bg) => !colors.isStableGlowPointNoop(role.sourceHex, bg)), + ); + } + + const changed = stableGlows.some( + (role) => desired.get(role.key) !== role.indeterminate, + ); + if (!changed) return false; + + // Re-resolve exactly once to refresh certificates/source metadata. Only + // stable Glow satellites are adopted; color/translucent roles remain under + // the existing adaptive contrast controller and do not snap. + const fresh = colors.resolveTheme(samples[0], theme); + const freshStable = validatedStableGlowsFrom(fresh); + const freshByKey = new Map(freshStable.map((role) => [role.key, role])); + const nextVars = { ...baseVars }; + for (const previous of stableGlows) { + const current = freshByKey.get(previous.key); + if (!current) { + throw new TypeError(`adaptTheme: stable Glow role '${previous.key}' disappeared`); + } + for (const key of stableVarKeys(previous)) delete nextVars[key]; + const indeterminate = desired.get(previous.key); + if (!indeterminate) { + for (const key of stableVarKeys(current)) { + if (typeof fresh.vars?.[key] !== "string") { + throw new TypeError( + `adaptTheme: determinate stable Glow '${previous.key}' lacks '${key}'`, + ); + } + nextVars[key] = fresh.vars[key]; + } + } + current.indeterminate = indeterminate; + } + + const previousVars = baseVars; + baseVars = nextVars; + stableGlows = freshStable.map((role) => { + const state = desired.get(role.key); + return state === undefined ? role : { ...role, indeterminate: state }; + }); + if (written !== null) { + // A stable certificate transition may coincide with an in-flight color + // ease. Patch only Glow satellites so the already-painted color overlay + // remains continuous; resetting `written`/`easing` here would snap every + // color role to its canonical destination. + const nextWritten = { ...written }; + const stableKeys = new Set( + stableGlows.flatMap((role) => stableVarKeys(role)), + ); + for (const key of stableKeys) { + if (typeof nextVars[key] === "string") { + if (previousVars[key] !== nextVars[key] || written[key] !== nextVars[key]) { + target.style.setProperty(key, nextVars[key]); + } + nextWritten[key] = nextVars[key]; + } else { + target.style.removeProperty(key); + delete nextWritten[key]; + } + } + written = nextWritten; + } + return true; + }; + // Begin an ease from the currently-applied colours toward the role colours. // `held` latches the per-role displayed blend so it only ever advances toward // the destination (strict mode) — see `stepEase`. @@ -343,15 +536,12 @@ export function adaptTheme(element, options) { if (easing.size === 0) applyRolesDirect(); }; - // Strict mode: the least blend in [e, 1] whose interpolated colour clears - // `floor` against EVERY background sample in `bgLums`. The destination (`to`, - // blend 1) is a freshly-solved legal colour, so it anchors the search; we - // bisect toward it from the natural ease value `e`. Returns `e` unchanged when - // the eased colour is already legal everywhere (the common case — no - // intervention). The returned blend is always floor-legal against the worst - // sample, except in the unavoidable case where even `to` is illegal against a - // sample that drifted further this frame — then it returns 1 (the most-legal - // colour we have) and the recheck loop re-solves. + // Legacy strict clamp: fixed-step bisection from the natural ease value `e` + // toward the freshly-solved destination. Oklab→clip→sRGB8 legality is not + // globally monotone, so this is a characterized compatibility selector, not + // a proof of the least or universally legal blend. #287 owns the finite + // hard-floor-safe replacement. If even `to` fails after background drift, the + // selector returns 1 and the recheck loop requests another solve. const floorBlend = (seg, e, bgLums, floor) => { const legalAt = (blend) => { const lum = segLum(seg, blend); @@ -418,8 +608,9 @@ export function adaptTheme(element, options) { // so on a frame where they drift favourably it could return a *lower* // blend than last frame — a backwards step toward the old colour, the // precise jarring reversal this mode exists to avoid. `held` clamps that - // out: the colour progresses monotonically from→to and never below the - // legal line on any sample. + // out: the scalar blend parameter never retreats. This latch alone is + // not a proof that the quantized colour stays above every floor; #287 + // owns that finite-path guarantee. blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held); seg.held = blend; } @@ -471,8 +662,9 @@ export function adaptTheme(element, options) { const now = nowArg ?? clock(); const samples = readSamples(); const key = samples.join("|"); - // Advance any in-flight ease first (against the live samples, so strict mode - // holds the legal floor every frame as the backdrop keeps drifting under it). + // Advance any in-flight ease first against the live samples. Legacy strict + // mode applies its characterized clamp here; it is not a universal + // per-frame floor certificate (#287). if (easing.size > 0) stepEase(now, samples, key); // Steady state: a static backdrop with no in-flight ease and no pending @@ -480,6 +672,7 @@ export function adaptTheme(element, options) { // backdrop, so the sustain timer can fire on one that changed once to a // failing value and then held. if (key === lastKey && easing.size === 0 && breachSince === null) return; + if (key !== lastKey) reconcileStableGlows(samples); lastKey = key; if (roles.length === 0) return; @@ -501,6 +694,7 @@ export function adaptTheme(element, options) { // reintroducing flicker when a re-solve overlaps a previous ease. const fromByVar = paintedNow(now, samples, key); solveAndAdopt(samples[worstIdx], now); + reconcileStableGlows(samples); beginEase(fromByVar, now); stepEase(now, samples, key); }; @@ -516,6 +710,7 @@ export function adaptTheme(element, options) { const samples = readSamples(); lastKey = samples.join("|"); solveAndAdoptWorst(samples, clock()); + reconcileStableGlows(samples); applyRolesDirect(); } @@ -527,6 +722,7 @@ export function adaptTheme(element, options) { lastKey = samples.join("|"); easing = new Map(); solveAndAdoptWorst(samples, clock()); + reconcileStableGlows(samples); applyRolesDirect(); // instant — a theme switch is intent, not drift }, start() { diff --git a/packages/colors/apply-theme.js b/packages/colors/apply-theme.js index e150bd9e..45844ec6 100644 --- a/packages/colors/apply-theme.js +++ b/packages/colors/apply-theme.js @@ -12,10 +12,11 @@ const LAB_VAR_PREFIX = "--lab-"; * * First clears every inline `--lab-*` custom property a previous call set on * this element, then writes every reachable role from `result.vars` via - * `setProperty`. Unreachable and zero-token (`none`) roles carry no colour, so - * they are absent from `vars` and are not written — the caller's CSS fallbacks - * stay in effect for those, including across theme re-application (a role - * reachable in the previous theme but not in the new one does not linger). + * `setProperty`. Unreachable, zero-token (`none`), and stable + * `glow-indeterminate` roles carry no selected CSS value, so they are absent + * from `vars` and are not written — the caller's CSS fallbacks stay in effect + * for those, including across theme re-application (a role reachable in the + * previous theme but not in the new one does not linger). * * @param {HTMLElement} element - The target element (e.g. `document.documentElement`). * @param {{ vars: Record }} result - A `resolveTheme(...)` result. diff --git a/packages/colors/effective-bg.d.ts b/packages/colors/effective-bg.d.ts index b964728a..fdbe8a27 100644 --- a/packages/colors/effective-bg.d.ts +++ b/packages/colors/effective-bg.d.ts @@ -41,8 +41,9 @@ export declare function oklabLerp(from: string, to: string, t: number): string; export declare function compositeStackToHex(layersFrontToBack: Rgba[], opaqueBase: Rgba): string; /** - * The opaque effective background `#RRGGBB` visible behind `element`'s content, - * by walking ancestors and alpha-compositing their `background-color`s. + * Legacy opaque reference-background estimate for the supported + * `background-color` subset. This is not a browser pixel observation; use an + * explicit sampled background when correctness depends on unsupported layers. */ export declare function effectiveBackground( element: unknown, diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index 2a512b80..3fff132b 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -3,13 +3,14 @@ // `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 honestly you need the **effective** background: the opaque colour a -// viewer actually sees behind the element's own content. +// 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). It is the bridge from the DOM's -// layered, translucent reality to the single solid hex the WASM core consumes. +// 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 @@ -24,9 +25,10 @@ // 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 return `null` (a -// dropped layer). If a surface's background is authored in one of those, pass the -// effective background explicitly via the `background` option. +// named colours beyond `transparent` — are NOT parsed and currently become a +// dropped layer for compatibility. That is not safe evidence: pass the +// background explicitly. Issue #283 owns the typed `Unknown` replacement for +// unsupported syntax, missing opaque bases and incomplete traversal. /** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */ @@ -183,9 +185,16 @@ function oklchAlpha(tok) { export function compositeOver(top, bottom) { const at = top[3]; const ab = bottom[3]; - const a = at + ab * (1 - at); + // Affine-форма математически равна expanded source-over и фиксирует + // объявленный byte-scale binary64 operation order. Это numerical profile, + // не утверждение глобальной монотонности: округление может менять локальный + // порядок соседних значений, а legacy WCAG EOTF дополнительно имеет seam. + const a = ab + at * (1 - ab); if (a === 0) return [0, 0, 0, 0]; - const c = (i) => (top[i] * at + bottom[i] * ab * (1 - at)) / a; + const c = (i) => { + const bottomPremultiplied = bottom[i] * ab; + return (bottomPremultiplied + at * (top[i] - bottomPremultiplied)) / a; + }; return [c(0), c(1), c(2), a]; } @@ -326,8 +335,8 @@ export function parseCssColorCached(css) { return hit; } -/** WCAG 2.1 relative luminance of r,g,b channels (0..255) — the normative - * 0.03928 / 12.92 / 2.4 constants, matching `adapt-theme`'s floor semantics. */ +/** Relative luminance of r,g,b channels (0..255) in the frozen original WCAG + * 2.1 (2018) profile: 0.03928 / 12.92 / 2.4, matching `adapt-theme`. */ function wcagLumChannels(r, g, b) { const lin = (c) => { const s = c / 255; @@ -402,10 +411,10 @@ export function lerpPairHex(pair, t) { return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]); } -/** WCAG relative luminance of `lerpPairHex(pair, t)` WITHOUT the `#RRGGBB` - * round-trip: channels are quantised to the exact bytes `toHex` would emit, - * then fed to the normative formula — so strict-mode bisection over `t` is - * ~20 flops instead of serialise + re-parse, at identical results. */ +/** Relative luminance of `lerpPairHex(pair, t)` in the frozen legacy WCAG 2.1 + * (2018) profile, WITHOUT the `#RRGGBB` round-trip: channels are quantised to + * the exact bytes `toHex` would emit, then fed to the same profile formula. + * This preserves value parity; it does not prove bisection monotonicity. */ export function lerpPairLuminance(pair, t) { if (t <= 0) return wcagLumChannels(pair.aBytes[0], pair.aBytes[1], pair.aBytes[2]); if (t >= 1) return wcagLumChannels(pair.bBytes[0], pair.bBytes[1], pair.bBytes[2]); @@ -442,13 +451,13 @@ export function compositeStackToHex(layersFrontToBack, opaqueBase) { } /** - * The opaque effective background `#RRGGBB` visible behind `element`'s own - * content, by walking ancestors and compositing their `background-color`s. + * 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 the - * base — matching how a browser shows the page's default canvas. + * 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`. diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index 920a18b5..60aafaee 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -11,13 +11,46 @@ export { LabColors, } from "./pkg/labcolors.js"; +// Curated public schema/result surface. wasm-bindgen's InitOutput and raw +// __wbg_* ABI helpers remain implementation details. export type { ThemeName, SolvedColor, - TranslucentRole, NoneRole, UnreachableRole, + TranslucentRole, + GlowDecisionProfileV1, + GlowLayerRecipeProfileV1, + GlowDiagnosticProfileV1, + GlowTargetStatusV1, + NumericalIndeterminacyV1, + GlowBitExactDecisionGuaranteeV1, + GlowLegacyDecisionGuaranteeV1, + GlowDecisionGuaranteeV1, + GlowDeterminateRoleBase, + GlowStableExactNoopRole, + GlowLegacyReachedRole, + GlowLegacyUnreachableRole, + GlowDeterminateRole, + GlowIndeterminateRoleBase, + GlowIndeterminateRole, + GlowRole, + MaterialAlphaGuaranteeBaseV1, + MaterialBisectionBracketGuaranteeV1, + MaterialTransparentEndpointGuaranteeV1, + MaterialOpaqueEndpointGuaranteeV1, + MaterialAlphaGuaranteeV1, + MaterialRoleBase, + MaterialSatisfiedTransparentRole, + MaterialSatisfiedBracketRole, + MaterialDegradedOpaqueRole, + MaterialRole, RoleResult, + ThemeAnchors, + LadderSource, + LadderPositionV1, + RoleRecipe, + ThemeConfig, ResolvedTheme, } from "./pkg/labcolors.js"; diff --git a/packages/colors/package-lock.json b/packages/colors/package-lock.json index a8de6756..07491794 100644 --- a/packages/colors/package-lock.json +++ b/packages/colors/package-lock.json @@ -1,13 +1,16 @@ { "name": "@labpics/colors", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labpics/colors", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", + "engines": { + "node": ">=22.11.0" + }, "devDependencies": { "typescript": "^5.5.0" } diff --git a/packages/colors/package.json b/packages/colors/package.json index 864a284b..06876103 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -1,7 +1,8 @@ { "name": "@labpics/colors", - "version": "0.9.1", - "description": "Framework-agnostic contrast engine: resolve accessible, perceptually-anchored colour roles for any background. WASM core, zero runtime dependencies.", + "version": "0.10.0", + "packageManager": "npm@11.9.0", + "description": "Contextual compiler and runtime resolver for client-owned color-token contracts. WASM core, zero runtime dependencies.", "type": "module", "license": "MIT", "publishConfig": { @@ -35,9 +36,12 @@ "default": "./effective-bg.js" }, "./pkg/labcolors_bg.wasm": "./pkg/labcolors_bg.wasm", + "./build-metadata.json": "./build-metadata.json", "./package.json": "./package.json" }, "files": [ + "LICENSE", + "build-metadata.json", "index.js", "index.d.ts", "apply-theme.js", @@ -55,9 +59,11 @@ ], "scripts": { "//build": "wasm-pack resolves --out-dir relative to the CRATE dir (crates/labcolors-wasm), not the cwd, so the ../../packages path is correct and unambiguous regardless of where npm runs it.", - "build": "wasm-pack build ../../crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors", + "build": "wasm-pack build ../../crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "node --test" + "test": "node --test", + "prepack": "npm run build && node ../../scripts/prepare-npm-package.mjs", + "release:verify": "node ../../scripts/verify-package-release.mjs" }, "devDependencies": { "typescript": "^5.5.0" @@ -76,6 +82,6 @@ "labpics" ], "engines": { - "node": ">=18" + "node": ">=22.11.0" } } diff --git a/packages/colors/smoke.consumer.ts b/packages/colors/smoke.consumer.ts index 9d89cf9e..3cfc325c 100644 --- a/packages/colors/smoke.consumer.ts +++ b/packages/colors/smoke.consumer.ts @@ -10,7 +10,155 @@ import init, { effectiveBackground, oklabLerp, } from "./index.js"; -import type { ResolvedTheme, RoleResult, ThemeName } from "./index.js"; +import type { + GlowDiagnosticProfileV1, + GlowDeterminateRole, + GlowDeterminateRoleBase, + GlowLayerRecipeProfileV1, + GlowTargetStatusV1, + MaterialRole, + MaterialRoleBase, + ResolvedTheme, + RoleResult, + ThemeName, +} from "./index.js"; + +declare const glowDeterminateCommon: GlowDeterminateRoleBase; +const requireGlowDeterminate = (_role: GlowDeterminateRole): void => {}; + +requireGlowDeterminate({ + ...glowDeterminateCommon, + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + selectionDiagnosticProfile: null, + targetStatus: "exact-noop-unreachable", + degraded: true, +}); +requireGlowDeterminate({ + ...glowDeterminateCommon, + decisionProfile: "legacy-platform-dependent-v1", + decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, + selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + targetStatus: "legacy-reached", + degraded: false, +}); +requireGlowDeterminate({ + ...glowDeterminateCommon, + decisionProfile: "legacy-platform-dependent-v1", + decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, + selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + targetStatus: "legacy-unreachable", + degraded: true, +}); + +// @ts-expect-error stable-профиль не может нести legacy-status. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: null, targetStatus: "legacy-reached", degraded: false }); +// @ts-expect-error legacy-профиль не может рекламировать bit-exact decision. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached", degraded: false }); +// @ts-expect-error exact no-op не выполняет CAM16 selection diagnostic. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "exact-noop-unreachable", degraded: true }); +// @ts-expect-error reached-ветвь не является degraded. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached", degraded: true }); +// @ts-expect-error Glow API не поддерживает outward decision guarantee. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "outward-interval-v1", lower: 0, upper: 1 }, selectionDiagnosticProfile: null, targetStatus: "exact-noop-unreachable", degraded: true }); + +function narrowGlowDeterminate(role: GlowDeterminateRole): void { + if (role.decisionProfile === "stable-v1") { + const status: "exact-noop-unreachable" = role.targetStatus; + const guarantee: "bit-exact" = role.decisionGuarantee.kind; + const diagnostic: null = role.selectionDiagnosticProfile; + const degraded: true = role.degraded; + void status; + void guarantee; + void diagnostic; + void degraded; + } else if (role.targetStatus === "legacy-reached") { + const degraded: false = role.degraded; + void degraded; + } else { + const status: "legacy-unreachable" = role.targetStatus; + const degraded: true = role.degraded; + void status; + void degraded; + } +} + +declare const materialCommon: MaterialRoleBase; +const requireMaterial = (_role: MaterialRole): void => {}; + +requireMaterial({ + ...materialCommon, + alpha: 0, + alphaGuarantee: { + kind: "transparent-endpoint-characterized-v1", + numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", + }, + alphaStatus: "satisfied", + guaranteed: true, +}); +requireMaterial({ + ...materialCommon, + alpha: 0.5, + alphaGuarantee: { + kind: "bisection-bracket-characterized-v1", + iterations: 60, + lowerAlpha: 0.49, + upperAlpha: 0.5, + numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", + }, + alphaStatus: "satisfied", + guaranteed: true, +}); +requireMaterial({ + ...materialCommon, + alpha: 1, + alphaGuarantee: { + kind: "opaque-endpoint-characterized-v1", + numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", + }, + alphaStatus: "degraded", + guaranteed: false, +}); + +// @ts-expect-error degraded несовместим с transparent endpoint. +requireMaterial({ ...materialCommon, alpha: 0, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded", guaranteed: false }); +// @ts-expect-error satisfied несовместим с opaque endpoint. +requireMaterial({ ...materialCommon, alpha: 1, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: true }); +// @ts-expect-error compatibility boolean выводится из typed status. +requireMaterial({ ...materialCommon, alpha: 0.5, alphaGuarantee: { kind: "bisection-bracket-characterized-v1", iterations: 60, lowerAlpha: 0.49, upperAlpha: 0.5, numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: false }); +// @ts-expect-error transparent endpoint конструктивно имеет alpha 0. +requireMaterial({ ...materialCommon, alpha: 0.25, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: true }); +// @ts-expect-error degraded opaque endpoint конструктивно имеет alpha 1. +requireMaterial({ ...materialCommon, alpha: 0.9, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded", guaranteed: false }); + +function narrowMaterial(role: MaterialRole): void { + if (role.alphaStatus === "degraded") { + const alpha: 1 = role.alpha; + const guarantee: "opaque-endpoint-characterized-v1" = role.alphaGuarantee.kind; + const guaranteed: false = role.guaranteed; + void alpha; + void guarantee; + void guaranteed; + return; + } + + const status: "satisfied" = role.alphaStatus; + const guaranteed: true = role.guaranteed; + void status; + void guaranteed; + if (role.alphaGuarantee.kind === "transparent-endpoint-characterized-v1") { + const guarantee: "transparent-endpoint-characterized-v1" = role.alphaGuarantee.kind; + void guarantee; + } else { + const guarantee: "bisection-bracket-characterized-v1" = role.alphaGuarantee.kind; + const upper: number = role.alphaGuarantee.upperAlpha; + void guarantee; + void upper; + } +} + +void narrowGlowDeterminate; +void narrowMaterial; async function consume(clientConfigJson: string): Promise { await init(); @@ -46,6 +194,20 @@ async function consume(clientConfigJson: string): Promise { const bg: string = result.background; void bg; + const glow: RoleResult = result.roles["fx-glow-brand"]; + if (glow.kind === "glow") { + const layerRecipeProfile: GlowLayerRecipeProfileV1 = glow.layerRecipeProfile; + const appearanceDiagnosticProfile: GlowDiagnosticProfileV1 = + glow.appearanceDiagnosticProfile; + const selectionDiagnosticProfile: GlowDiagnosticProfileV1 | null = + glow.selectionDiagnosticProfile; + const targetStatus: GlowTargetStatusV1 = glow.targetStatus; + void layerRecipeProfile; + void appearanceDiagnosticProfile; + void selectionDiagnosticProfile; + void targetStatus; + } + applyTheme(document.documentElement, result); // Current effectiveBackground returns the legacy solid reference estimate, diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 0e7106e0..8f5a0a22 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -80,8 +80,8 @@ const floorRole = (hex, lc, legalFloor) => ({ }, }); -// WCAG 2.1 contrast ratio between two #RRGGBB — the exact normative transcription -// (0.03928 split, 2.4 exponent), the yardstick the strict clamp must respect. +// Contrast ratio between two #RRGGBB in the frozen original WCAG 2.1 (2018) +// profile (0.03928 split, 2.4 exponent), matching the versioned core contract. function wcagContrast(fg, bg) { const lum = (hex) => { const n = parseInt(hex.slice(1), 16); @@ -130,6 +130,599 @@ test("applies the resolved set immediately on creation", () => { assert.equal(h.el.props.get("--lab-label-primary"), "#000000"); }); +test("stable Glow class changes re-resolve and clear/restore satellites synchronously", () => { + const el = fakeElement(); + let bg = "#FFFFFF"; + let resolveCount = 0; + const cssVar = "--lab-fx"; + const determinate = { + vars: { + [cssVar]: "oklch(70% 0.1 280)", + [`${cssVar}-core`]: "oklch(80% 0.1 280)", + [`${cssVar}-alpha`]: "0.5", + }, + roles: { + fx: { + kind: "glow", + cssVar, + coreHex: "#D8CEFF", + haloHex: "#C0B2FA", + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + compositeProfile: "encoded-srgb8-screen-v1", + compositeGuarantee: "bit-exact", + layerRecipeProfile: "cam16-jprime-oklab-cusp-v1", + appearanceDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + selectionDiagnosticProfile: null, + constraintLayer: "halo", + targetStatus: "exact-noop-unreachable", + degraded: true, + }, + }, + }; + const indeterminate = { + vars: {}, + roles: { + fx: { + kind: "glow-indeterminate", + cssVar, + sourceHex: "#C0B2FA", + decisionProfile: "stable-v1", + numericalSiteId: "glow-target-or-maximum-v1", + constraintLayer: "halo", + reason: "sound-bound-unavailable", + bounds: { kind: "unavailable" }, + }, + }, + }; + const colors = { + resolveTheme(background) { + resolveCount++; + return background === "#FFFFFF" ? determinate : indeterminate; + }, + recheckContrast() { + return []; + }, + isStableGlowPointNoop(_source, background) { + return background === "#FFFFFF"; + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => bg, + target: el, + now: () => 1000, + win: {}, + sustainMs: 10_000, + dwellMs: 10_000, + }); + assert.equal(resolveCount, 1); + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.ok(el.props.has(key), `${key} present for exact no-op`); + } + + bg = "#FEFEFE"; + ctrl.tick(); + assert.equal(resolveCount, 2, "class transition bypasses contrast sustain/dwell"); + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.equal(el.props.has(key), false, `${key} cleared in the same tick`); + } + + bg = "#FDFDFD"; + ctrl.tick(); + assert.equal(resolveCount, 2, "same Indeterminate class keeps the cheap adaptive path"); + + bg = "#FFFFFF"; + ctrl.tick(); + assert.equal(resolveCount, 3, "return to exact no-op selectively re-resolves"); + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.ok(el.props.has(key), `${key} restored synchronously`); + } +}); + +test("stable Glow rejects a missing exact recheck capability or malformed evidence", () => { + const stable = { + vars: { + "--lab-fx": "oklch(70% 0.1 280)", + "--lab-fx-core": "oklch(80% 0.1 280)", + "--lab-fx-alpha": "0.5", + }, + roles: { + fx: { + kind: "glow", + cssVar: "--lab-fx", + coreHex: "#D8CEFF", + haloHex: "#C0B2FA", + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + compositeProfile: "encoded-srgb8-screen-v1", + compositeGuarantee: "bit-exact", + layerRecipeProfile: "cam16-jprime-oklab-cusp-v1", + appearanceDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + selectionDiagnosticProfile: null, + constraintLayer: "halo", + targetStatus: "exact-noop-unreachable", + degraded: true, + }, + }, + }; + const options = { + theme: "light", + background: () => "#FFFFFF", + win: {}, + }; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { resolveTheme: () => stable, recheckContrast: () => [] }, + }), + /isStableGlowPointNoop/u, + ); + + const malformed = structuredClone(stable); + malformed.roles.fx.decisionGuarantee = { kind: "legacy-platform-dependent-v1" }; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => malformed, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + ); + + const falseSelectionDiagnostic = structuredClone(stable); + falseSelectionDiagnostic.roles.fx.selectionDiagnosticProfile = + "cam16-ucs-jprime-li2017-v1"; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => falseSelectionDiagnostic, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + ); + + for (const field of [ + "layerRecipeProfile", + "appearanceDiagnosticProfile", + "selectionDiagnosticProfile", + ]) { + const missingProfile = structuredClone(stable); + delete missingProfile.roles.fx[field]; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => missingProfile, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + `${field} must not collapse at the adaptive boundary`, + ); + } + + const genericStatus = structuredClone(stable); + genericStatus.roles.fx.targetStatus = "unreachable"; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => genericStatus, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + "generic unreachable must not erase exact provenance", + ); + + const falseDegraded = structuredClone(stable); + falseDegraded.roles.fx.degraded = false; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => falseDegraded, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + ); + + const incomplete = structuredClone(stable); + delete incomplete.vars["--lab-fx-alpha"]; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => incomplete, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks BitExact evidence/u, + ); + + const wrongSite = { + vars: {}, + roles: { + fx: { + kind: "glow-indeterminate", + cssVar: "--lab-fx", + sourceHex: "#C0B2FA", + decisionProfile: "stable-v1", + numericalSiteId: "some-future-site", + constraintLayer: "halo", + reason: "sound-bound-unavailable", + bounds: { kind: "unavailable" }, + }, + }, + }; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => wrongSite, + recheckContrast: () => [], + isStableGlowPointNoop: () => false, + }, + }), + /lacks lawful Indeterminate evidence/u, + ); + + const mismatchedCssVar = structuredClone(wrongSite); + mismatchedCssVar.roles.fx.numericalSiteId = "glow-target-or-maximum-v1"; + mismatchedCssVar.roles.fx.cssVar = "--lab-other"; + mismatchedCssVar.vars = { "--lab-fx": "forbidden-fallback" }; + const mismatchTarget = fakeElement(); + assert.throws( + () => + adaptTheme(mismatchTarget, { + ...options, + colors: { + resolveTheme: () => mismatchedCssVar, + recheckContrast: () => [], + isStableGlowPointNoop: () => false, + }, + }), + /non-canonical cssVar/u, + ); + assert.equal( + mismatchTarget.props.size, + 0, + "mismatched cssVar must not smuggle a fallback var into the target", + ); + + for (const decisionProfile of [undefined, "stable-v2", "stable-v1-typo"]) { + const unknownProfile = structuredClone(stable); + if (decisionProfile === undefined) delete unknownProfile.roles.fx.decisionProfile; + else unknownProfile.roles.fx.decisionProfile = decisionProfile; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => unknownProfile, + recheckContrast: () => [], + isStableGlowPointNoop: () => true, + }, + }), + /lacks an explicit known decisionProfile/u, + `unknown profile ${String(decisionProfile)} must not retain un-rechecked Glow vars`, + ); + } + + const legacyIndeterminate = structuredClone(wrongSite); + legacyIndeterminate.roles.fx.decisionProfile = "legacy-platform-dependent-v1"; + legacyIndeterminate.roles.fx.numericalSiteId = "glow-target-or-maximum-v1"; + assert.throws( + () => + adaptTheme(fakeElement(), { + ...options, + colors: { + resolveTheme: () => legacyIndeterminate, + recheckContrast: () => [], + }, + }), + /legacy Glow 'fx' cannot be Indeterminate/u, + ); +}); + +test("legacy Glow validates correlated provenance before adopting external results", () => { + const cssVar = "--lab-fx"; + const legacyReached = { + vars: { + [cssVar]: "oklch(70% 0.1 280)", + [`${cssVar}-core`]: "oklch(80% 0.1 280)", + [`${cssVar}-alpha`]: "0.5", + }, + roles: { + fx: { + kind: "glow", + cssVar, + coreHex: "#D8CEFF", + haloHex: "#C0B2FA", + alpha: 0.5, + alphaCss: "0.5", + compositeProfile: "encoded-srgb8-screen-v1", + compositeGuarantee: "bit-exact", + layerRecipeProfile: "cam16-jprime-oklab-cusp-v1", + appearanceDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + decisionProfile: "legacy-platform-dependent-v1", + decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, + constraintLayer: "halo", + targetDj: 2.3006, + targetStatus: "legacy-reached", + haloCompositeHex: "#C0B2FA", + haloAchievedDj: 2.4, + coreCompositeHex: "#D8CEFF", + coreAchievedDj: 3.1, + achievedDj: 2.4, + degraded: false, + css: "oklch(70% 0.1 280)", + }, + }, + }; + const options = { + theme: "light", + background: () => "#FFFFFF", + win: {}, + }; + const colorsFor = (result) => ({ + resolveTheme: () => result, + recheckContrast: () => [], + }); + + const accepted = fakeElement(); + const controller = adaptTheme(accepted, { + ...options, + colors: colorsFor(legacyReached), + }); + assert.equal(accepted.props.get(cssVar), legacyReached.vars[cssVar]); + controller.stop(); + + const legacyUnreachable = structuredClone(legacyReached); + legacyUnreachable.roles.fx.targetStatus = "legacy-unreachable"; + legacyUnreachable.roles.fx.degraded = true; + const acceptedUnreachable = fakeElement(); + adaptTheme(acceptedUnreachable, { + ...options, + colors: colorsFor(legacyUnreachable), + }).stop(); + assert.equal( + acceptedUnreachable.props.get(cssVar), + legacyUnreachable.vars[cssVar], + ); + + const malformedCases = [ + ["bit-exact legacy decision", (role) => (role.decisionGuarantee = { kind: "bit-exact" })], + ["null legacy selection", (role) => (role.selectionDiagnosticProfile = null)], + ["generic exact status", (role) => (role.targetStatus = "exact-noop-unreachable")], + ["reached marked degraded", (role) => (role.degraded = true)], + ["unknown recipe", (role) => (role.layerRecipeProfile = "future-recipe-v2")], + ["unknown composite", (role) => (role.compositeProfile = "browser-screen-v1")], + ]; + for (const [name, mutate] of malformedCases) { + const malformed = structuredClone(legacyReached); + mutate(malformed.roles.fx); + const target = fakeElement(); + assert.throws( + () => + adaptTheme(target, { + ...options, + colors: colorsFor(malformed), + }), + /legacy Glow 'fx' lacks lawful legacy evidence/u, + name, + ); + assert.equal(target.props.size, 0, `${name}: invalid result must not be applied`); + } +}); + +test("stable Glow invalidation does not snap an in-flight color ease", () => { + const el = fakeElement(); + let bg = "#FFFFFF"; + let now = 1000; + let labelHex = "#000000"; + const cssVar = "--lab-fx"; + const isWhite = (value) => value.replace(/^#/u, "").toUpperCase() === "FFFFFF"; + const colors = { + resolveTheme(background) { + const noop = isWhite(background); + return { + vars: { + "--lab-label": labelHex, + ...(noop + ? { + [cssVar]: "oklch(70% 0.1 280)", + [`${cssVar}-core`]: "oklch(80% 0.1 280)", + [`${cssVar}-alpha`]: "0.5", + } + : {}), + }, + roles: { + label: { + kind: "color", + cssVar: "--lab-label", + hex: labelHex, + lc: 100, + }, + fx: noop + ? { + kind: "glow", + cssVar, + coreHex: "#D8CEFF", + haloHex: "#C0B2FA", + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + compositeProfile: "encoded-srgb8-screen-v1", + compositeGuarantee: "bit-exact", + layerRecipeProfile: "cam16-jprime-oklab-cusp-v1", + appearanceDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + selectionDiagnosticProfile: null, + constraintLayer: "halo", + targetStatus: "exact-noop-unreachable", + degraded: true, + } + : { + kind: "glow-indeterminate", + cssVar, + sourceHex: "#C0B2FA", + decisionProfile: "stable-v1", + numericalSiteId: "glow-target-or-maximum-v1", + constraintLayer: "halo", + reason: "sound-bound-unavailable", + bounds: { kind: "unavailable" }, + }, + }, + }; + }, + recheckContrast() { + return [10, 10]; + }, + isStableGlowPointNoop(_source, background) { + return isWhite(background); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => bg, + target: el, + now: () => now, + win: {}, + easeMs: 100, + sustainMs: 120, + dwellMs: 250, + }); + + bg = "FFFFFF"; + ctrl.tick(); // arm contrast breach while stable Glow remains exact no-op + labelHex = "#F0F0F0"; + now = 1300; + bg = "#fff"; + ctrl.tick(); // sustained color breach -> begin ease + + now = 1350; + bg = "#FEFEFE"; + ctrl.tick(); // paint midpoint, then synchronously invalidate only Glow + const painted = el.props.get("--lab-label"); + assert.match(painted, /^#[0-9A-Fa-f]{6}$/u); + assert.notEqual(painted, "#000000"); + assert.notEqual(painted, "#F0F0F0", "stable invalidation must preserve painted midpoint"); + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.equal(el.props.has(key), false, `${key} cleared without snapping label`); + } +}); + +test("a color re-solve cannot reintroduce stable Glow vars unsafe for another sample", () => { + const el = fakeElement(); + let samples = ["#FFFFFF", "#FEFEFE"]; + let now = 1000; + let failing = false; + const cssVar = "--lab-fx"; + const colors = { + resolveTheme(background) { + const noop = background === "#FFFFFF"; + return { + vars: { + "--lab-label": "#000000", + ...(noop + ? { + [cssVar]: "oklch(70% 0.1 280)", + [`${cssVar}-core`]: "oklch(80% 0.1 280)", + [`${cssVar}-alpha`]: "0.5", + } + : {}), + }, + roles: { + label: { + kind: "color", + cssVar: "--lab-label", + hex: "#000000", + lc: 100, + }, + fx: noop + ? { + kind: "glow", + cssVar, + coreHex: "#D8CEFF", + haloHex: "#C0B2FA", + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + compositeProfile: "encoded-srgb8-screen-v1", + compositeGuarantee: "bit-exact", + layerRecipeProfile: "cam16-jprime-oklab-cusp-v1", + appearanceDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", + selectionDiagnosticProfile: null, + constraintLayer: "halo", + targetStatus: "exact-noop-unreachable", + degraded: true, + } + : { + kind: "glow-indeterminate", + cssVar, + sourceHex: "#C0B2FA", + decisionProfile: "stable-v1", + numericalSiteId: "glow-target-or-maximum-v1", + constraintLayer: "halo", + reason: "sound-bound-unavailable", + bounds: { kind: "unavailable" }, + }, + }, + }; + }, + recheckContrast(background) { + return [failing && background === "#FFFFFF" ? 10 : 100, 10]; + }, + isStableGlowPointNoop(_source, background) { + return background === "#FFFFFF"; + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => samples, + target: el, + now: () => now, + win: {}, + sustainMs: 120, + dwellMs: 250, + easeMs: 100, + }); + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.equal(el.props.has(key), false, `${key} unsafe for the second initial sample`); + } + + samples = ["#FFFFFF", "#FDFDFD"]; + failing = true; + ctrl.tick(); // arm color breach; stable class remains aggregate Indeterminate + now = 1300; + ctrl.tick(); // color worstIdx=0 (white) -> must reconcile second sample again + for (const key of [cssVar, `${cssVar}-core`, `${cssVar}-alpha`]) { + assert.equal(el.props.has(key), false, `${key} must not reappear after color re-solve`); + } +}); + test("holds (no re-solve) while colours still pass; the dropFraction margin tolerates small drops", () => { const h = harness(); // A small drop (95 of 100; tolerance keeps to 80) → still passing → hold. @@ -273,7 +866,7 @@ function easeContrasts({ strict }) { return { h, out }; } -test("strict floor-clamp holds the WCAG floor every frame of the ease", () => { +test("legacy strict clamp holds the floor on the canonical polarity-crossing fixture", () => { const { h, out } = easeContrasts({ strict: true }); assert.equal(h.colors.resolveCount(), 2); for (const c of out) { @@ -283,7 +876,7 @@ test("strict floor-clamp holds the WCAG floor every frame of the ease", () => { assert.equal(h.el.props.get("--lab-label-primary"), "#FFFFFF"); }); -test("strict floor-clamp is monotone (contrast never steps backwards)", () => { +test("legacy strict fixture has non-regressing sampled contrast", () => { const { out } = easeContrasts({ strict: true }); for (let i = 1; i < out.length; i++) { assert.ok( @@ -293,9 +886,9 @@ test("strict floor-clamp is monotone (contrast never steps backwards)", () => { } }); -test("strict floor-clamp never reverses the colour when the background drifts favourably", () => { - // The structural guarantee: the displayed colour only ever advances from→to, - // never retreats — even when a favourably-drifting (darkening) background would +test("held latch never reverses the scalar blend when the background drifts favourably", () => { + // The structural guarantee is only on the scalar blend parameter: it advances + // from→to even when a favourably-drifting (darkening) background would // let the stateless floor solver pick a LOWER blend frame to frame. Without the // `held` latch the grey value would step back down; with it, it is monotone. const h = harness({ strict: true, easeMs: 400 }); // long ease so bg drift dominates @@ -324,7 +917,7 @@ test("strict floor-clamp never reverses the colour when the background drifts fa } }); -test("the default (free) ease dips below the floor mid-transition — what strict fixes", () => { +test("the default ease dips below the floor on the canonical strict comparison fixture", () => { const { out } = easeContrasts({ strict: false }); assert.ok( out.some((c) => c < 4.5), @@ -401,7 +994,7 @@ test("initial apply re-solves against the worst sample of a varying backdrop", ( assert.equal(colors.lastResolveBg(), "#202020"); }); -test("strict worst-case: the floor is held against the hardest sample every frame", () => { +test("legacy strict clamp holds the canonical hardest-sample fixture", () => { let samples = ["#0A0A0A"]; // passing at construction const h = harness({ strict: true, easeMs: 100, background: () => samples }); h.colors.setResolve(floorRole("#FFFFFF", 100, 4.5)); diff --git a/packages/colors/test/chain-invariants.test.mjs b/packages/colors/test/chain-invariants.test.mjs index 03be2dcb..0b16a31f 100644 --- a/packages/colors/test/chain-invariants.test.mjs +++ b/packages/colors/test/chain-invariants.test.mjs @@ -9,11 +9,12 @@ // внутри солвера, и без параллельной копии физики контраста. // // Что уже закрыто в другом месте (НЕ дублируем): -// - core `oklch.rs::round_trip_is_byte_exact_*` — emit↔parse байт-точны на КУБЕ; +// - core `oklch.rs::round_trip_is_byte_exact_*` — emit↔parse байт-точны на +// решётке шага 5 и полном сером ramp (не объявлен полный куб); // - `oklch-parse.test.mjs` — parseCssColor декодит эмиссию на 16 фикстурах; // - core `property_invariants.rs::every_floored_role_clears_its_wcag_floor…` — // пол на СОБСТВЕННОМ hex солвера (не на репарснутой строке); -// - wasm `wasm_parity.rs` — граница == нативный резолв, роль-в-роль. +// - wasm `wasm_parity.rs` — JS-граница == core-оракул внутри того же wasm runtime. // Дыра: КОМПОЗИЦИЯ этих доказательств на ЖИВОМ корпусе ролей — «цвет, который // браузер соберёт из emitted vars, всё ещё проходит свой пол/таргет» — как // единая цепочка, а не транзитивность двух изолированных проверок. Для @@ -25,6 +26,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { initSync, LabColors } from "../pkg/labcolors.js"; +import { applyTheme } from "../apply-theme.js"; import { parseCssColor, compositeOver, toHex } from "../effective-bg.js"; // Инициализация wasm в node: pkg собран под `--target web` (fetch по URL), а в @@ -34,13 +36,6 @@ initSync({ module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), }); -/// Максимальная поканальная дельта двух `#RRGGBB` в LSB (8-бит ступенях). -function channelDelta(hexA, hexB) { - const a = parseCssColor(hexA); - const b = parseCssColor(hexB); - return Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2])); -} - // Замороженный SSOT-паспорт labui — тот же, что читает wasm-parity. Единый вход // и для движка, и для декларации алиасов ниже, чтобы стороны не разошлись. const PASSPORT_PATH = new URL( @@ -123,39 +118,13 @@ test("legality survives serialization: each solid role's emitted var reparses to }); // ───────────────────────────────────────────────────────────────────────────── -// ЛЕГАЛЬНОСТЬ НАСКВОЗЬ — полупрозрачные роли (композит, как его соберёт браузер) +// ЛЕГАЛЬНОСТЬ НАСКВОЗЬ — полупрозрачные роли (encoded-sRGB8 reference) // ───────────────────────────────────────────────────────────────────────────── -// НАХОДКА (репро ниже, зафиксирована в PR; причина заземлена на исходники -// labcolors-core): для 3 из 1699 полупрозрачных сэмплов корпуса (все — тинт -// #C0B2FA при низкой α над чистым чёрным в dark-ic) композит, СОБРАННЫЙ -// браузером из эмитированной строки, расходится с движковым `compositeHex` -// ровно на 1 LSB (#17161F vs #17161E и т.п.). -// ПРИЧИНА — РАЗНОЕ ПРОСТРАНСТВО АРИФМЕТИКИ, не потеря точности тинта: тинт -// КВАНТУЕТСЯ в 8 бит В ОБОИХ путях (semantic.rs:1977 `quantise_encoded` ДО -// `composite_over_encoded`), α одна. Но движок композитит в нормализованном -// encoded-[0,1] (`α·(byte/255)`), затем hex_from_srgb_encoded делает `·255·round` -// (srgb.rs:196); браузер (`compositeOver`) — в 0–255 (`α·byte`). На α·byte ровно -// = 30.5 (тинт-канал 250, α=0.122): движок `0.122·(250/255)·255 = 30.4999… → -// round → 30` (#…1E), браузер `250·0.122 = 30.5 → round → 31` (#…1F). Округление -// точной половины расходится, потому что ÷255·255 стягивает 30.5 к 30.4999. -// Следствие: обещанный `compositeHex`/Lc/WCAG считаны в другом пространстве, -// чем то, что реально отрендерит браузер (суб-JND, но реальный выходной gap). -// Живёт в эмиссии labcolors-core — не чиним здесь (зона солвера), фиксируем -// границей. Возможный фикс ядра: композитить в том же (байтовом) пространстве, -// что и браузер, — тогда обещанное == отрендеренное. Инвариант ниже строгий на -// том, что ЭМИССИЯ СТРОКИ (тинт/α) ничего не теряет побайтно, и держит композит -// в истинной границе ≤1 LSB — дрейф ≥2 LSB или потеря тинта/α упадёт RED. -const COMPOSITE_QUANT_LSB = 1; -// Известная верхняя граница числа расхождений на текущем корпусе (THEMES× -// BACKGROUNDS×паспорт). Пин: разрастание gap (в другие темы/фоны/роли) поднимет -// число выше и упадёт RED — характеризация защищает и КОЛИЧЕСТВО, и ЛОКАЦИЮ. -// Волна 1 (закон категориальных зон) сменила info-сентимент с пурпура на синий -// фокус — прежние 3 near-black dark-ic расхождения исчезли, осталась РОВНО 1 -// benign ≤1-LSB точка (см. локацию ниже). Число снижено 3 → 1. -const KNOWN_COMPOSITE_DIVERGENCES = 1; - -test("translucent serialization fidelity: emitted tint+alpha round-trip exactly; browser composite matches the promise within the ≤1-LSB quantization bound", () => { +// Численный контракт здесь — encoded-sRGB8 source-over reference, реализованный +// штатным consumer-кодом. Это проверка единства engine↔package, а не заявление, +// что любой renderer без явно заданного color-management профиля совпадёт с ним. +test("translucent serialization fidelity: emitted tint+alpha, reference composite and reported metrics agree exactly", () => { const e = engine(); let translucentChecked = 0; @@ -185,18 +154,20 @@ test("translucent serialization fidelity: emitted tint+alpha round-trip exactly; role.tintHex, `${theme}/${bg}/${key}: reparsed tint != reported tintHex (serialization loss)`, ); - // HARD: α реконструируется в пределах точности эмиссии (4 знака). + // Вычисленная α — часть сертификата композита: строка обязана вернуть + // тот же binary64, а не близкое округлённое значение. assert.ok( - Math.abs(alpha - role.alpha) < 5e-5, - `${theme}/${bg}/${key}: reparsed alpha ${alpha} drifted from ${role.alpha}`, + Object.is(alpha, role.alpha), + `${theme}/${bg}/${key}: reparsed alpha ${alpha} != reported ${role.alpha}`, ); - // Композит, собранный браузером из ЭМИТИРОВАННОГО тинта, — в истинной - // границе ≤1 LSB от обещанного (см. находку выше). Дрейф ≥2 = регрессия. + // Reference-композит из эмитированных значений обязан совпасть с + // сертификатом побайтно: допуск скрыл бы другой цвет и другие метрики. const compHex = toHex(compositeOver(parsed, [bgParsed[0], bgParsed[1], bgParsed[2], 1])); - assert.ok( - channelDelta(compHex, role.compositeHex) <= COMPOSITE_QUANT_LSB, - `${theme}/${bg}/${key}: composite ${compHex} drifts >${COMPOSITE_QUANT_LSB} LSB from promised ${role.compositeHex}`, + assert.equal( + compHex, + role.compositeHex, + `${theme}/${bg}/${key}: reference composite ${compHex} != promised ${role.compositeHex}`, ); // Самосогласованность движка: перепроверка ЕГО ЖЕ compositeHex @@ -216,58 +187,6 @@ test("translucent serialization fidelity: emitted tint+alpha round-trip exactly; assert.ok(translucentChecked > 0, "no translucent roles exercised — sweep is vacuous"); }); -// Характеризация НАХОДКИ: пин ТЕКУЩЕГО поведения по трём осям — ВЕЛИЧИНА (≤1 LSB), -// КОЛИЧЕСТВО (≤ известного) и ЛОКАЦИЯ (единственная info-заливка light-ic/#FFFFFF; -// Волна 1 сместила её с near-black dark-ic). Тест НЕ узаконивает баг: -// • починят движок (композит в байтовом пространстве) → расхождения исчезнут, -// `divergent.length > 0` упадёт → форс-ревью; -// • разрастётся gap (другие темы/фоны/роли или >1 LSB) → величина/количество/ -// локация превысят пин → RED. -// Без пина количества и локации регрессия, размазавшая тот же ≤1-LSB зазор на -// сотню сэмплов или в light-темы, прошла бы зелёной — находка тихо сгнила бы. -test("characterization: the composite quantization gap is real, bounded to ≤1 LSB, count-capped, and confined to the light-ic info fill (pins current behaviour)", () => { - const e = engine(); - let maxObservedDelta = 0; - const divergent = []; - for (const theme of THEMES) { - for (const bg of BACKGROUNDS) { - const res = e.resolveTheme(bg, theme); - const bgP = parseCssColor(bg); - for (const [key, role] of Object.entries(res.roles)) { - if (role.kind !== "translucent") continue; - const parsed = parseCssColor(res.vars[role.cssVar]); - const compHex = toHex(compositeOver(parsed, [bgP[0], bgP[1], bgP[2], 1])); - const d = channelDelta(compHex, role.compositeHex); - maxObservedDelta = Math.max(maxObservedDelta, d); - if (d > 0) divergent.push(`${theme}/${bg}/${key}`); - } - } - } - // ВЕЛИЧИНА: никогда больше 1 LSB. - assert.ok(maxObservedDelta <= 1, `composite quantization gap exceeded 1 LSB: ${maxObservedDelta}`); - // СУЩЕСТВОВАНИЕ: расхождение реально сегодня (иначе движок, вероятно, починили). - assert.ok( - divergent.length > 0, - "composite gap no longer reproduces — engine may compose in byte space now; revisit the PR finding", - ); - // КОЛИЧЕСТВО: не больше известной верхней границы — разрастание gap → RED. - assert.ok( - divergent.length <= KNOWN_COMPOSITE_DIVERGENCES, - `composite gap spread: ${divergent.length} divergences > known ${KNOWN_COMPOSITE_DIVERGENCES} — ${divergent.join(", ")}`, - ); - // ЛОКАЦИЯ: единственное расхождение — низкоальфовая info-заливка на белом в - // light-ic (`fill-info-quaternary`, α≈0.02). Волна 1 (закон категориальных зон) - // сменила info-сентимент с пурпура на синий фокус — та же природа зазора - // (8-бит квантование браузерного композита), новая точка от нового цвета. - // Утечка в другую роль/тему/фон = смена природы находки → форс-ревью. - for (const loc of divergent) { - assert.ok( - loc === "light-ic/#FFFFFF/fill-info-quaternary", - `composite gap escaped the known info-fill point: ${loc}`, - ); - } -}); - // ───────────────────────────────────────────────────────────────────────────── // GLOW — сателлиты эмиссии // ───────────────────────────────────────────────────────────────────────────── @@ -290,24 +209,106 @@ test("glow roles emit halo primary + -core/-alpha satellites, all well-formed", `${theme}/${bg}/${key}: primary var must mirror halo css`, ); assert.ok(parseCssColor(res.vars[role.cssVar]), `${theme}/${bg}/${key}: halo must parse`); + assert.equal( + toHex(rgb(parseCssColor(res.vars[role.cssVar]))), + role.haloHex, + `${theme}/${bg}/${key}: halo css must reconstruct haloHex`, + ); // Сателлит -core: валидная oklch-строка. const core = res.vars[`${role.cssVar}-core`]; assert.ok(core && parseCssColor(core), `${theme}/${bg}/${key}: -core must be valid oklch`); + assert.equal( + toHex(rgb(parseCssColor(core))), + role.coreHex, + `${theme}/${bg}/${key}: core css must reconstruct coreHex`, + ); - // Сателлит -alpha: конечное число в (0,1]. + // Сателлит -alpha — буквальный SSOT, не повторное округление числа. const alphaVar = res.vars[`${role.cssVar}-alpha`]; const a = Number(alphaVar); assert.ok( Number.isFinite(a) && a > 0 && a <= 1, `${theme}/${bg}/${key}: -alpha must be a number in (0,1]: ${alphaVar}`, ); + assert.equal(alphaVar, role.alphaCss, `${theme}/${bg}/${key}: alpha var != alphaCss`); + assert.ok(Object.is(a, role.alpha), `${theme}/${bg}/${key}: alpha lost binary64 bits`); + + assert.equal(role.constraintLayer, "halo"); + assert.equal(role.compositeProfile, "encoded-srgb8-screen-v1"); + assert.equal(role.compositeGuarantee, "bit-exact"); + assert.equal(role.layerRecipeProfile, "cam16-jprime-oklab-cusp-v1"); + assert.equal(role.appearanceDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); + assert.equal(role.selectionDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); + assert.equal(role.decisionProfile, "legacy-platform-dependent-v1"); + assert.deepEqual(role.decisionGuarantee, { + kind: "legacy-platform-dependent-v1", + }); + assert.ok(["legacy-reached", "legacy-unreachable"].includes(role.targetStatus)); + assert.equal(role.degraded, role.targetStatus === "legacy-unreachable"); + assert.ok(Object.is(role.achievedDj, role.haloAchievedDj)); + if (role.targetStatus === "legacy-reached") { + assert.ok(role.haloAchievedDj >= role.targetDj); + } else { + assert.ok(role.haloAchievedDj < role.targetDj); + } + + // Оба поля — point-reference замеры ИЗОЛИРОВАННЫХ слоёв. Полный + // spatial stack без геометрии здесь намеренно не реконструируется. + const [br, bgc, bb] = parseCssColor(bg); + const screenHex = (layerCss) => { + const [lr, lg, lb] = parseCssColor(layerCss); + // Тот же конечный byte-reference, что у ядра: нормализация + // byte/255 перед обратным умножением сдвигает точные half-tie. + const channel = (background, layer) => + background + a * layer * (255 - background) / 255; + return toHex([channel(br, lr), channel(bgc, lg), channel(bb, lb)]); + }; + assert.equal(screenHex(res.vars[role.cssVar]), role.haloCompositeHex); + assert.equal(screenHex(core), role.coreCompositeHex); } } } assert.ok(glowChecked > 0, "no glow roles exercised — passport should carry glows"); }); +test("stable glow indeterminate emits no fallback vars and clears previous legacy satellites", () => { + const legacy = engine().resolveTheme("#101012", "dark"); + assert.ok(legacy.vars["--lab-fx-glow-brand"]); + + const stablePassport = structuredClone(PASSPORT_OBJ); + const brandGlow = stablePassport.roles.find(({ name }) => name === "fx-glow-brand"); + assert.ok(brandGlow, "anti-vacuum: passport must contain fx-glow-brand"); + brandGlow.recipe.decision_profile = "stable-v1"; + const stableEngine = new LabColors(); + stableEngine.loadConfig(JSON.stringify(stablePassport)); + const stable = stableEngine.resolveTheme("#101012", "dark"); + const role = stable.roles["fx-glow-brand"]; + assert.equal(role.kind, "glow-indeterminate"); + assert.equal(role.numericalSiteId, "glow-target-or-maximum-v1"); + assert.equal(role.reason, "sound-bound-unavailable"); + assert.equal(role.bounds.kind, "unavailable"); + for (const key of [role.cssVar, `${role.cssVar}-core`, `${role.cssVar}-alpha`]) { + assert.equal(stable.vars[key], undefined, `${key}: no implicit legacy fallback`); + } + + const props = new Map(); + const element = { + style: { + get length() { return props.size; }, + item(index) { return [...props.keys()][index] ?? ""; }, + setProperty(key, value) { props.set(key, value); }, + removeProperty(key) { props.delete(key); }, + }, + }; + applyTheme(element, legacy); + assert.ok(props.has("--lab-fx-glow-brand-alpha")); + applyTheme(element, stable); + assert.ok(!props.has("--lab-fx-glow-brand")); + assert.ok(!props.has("--lab-fx-glow-brand-core")); + assert.ok(!props.has("--lab-fx-glow-brand-alpha")); +}); + // ───────────────────────────────────────────────────────────────────────────── // СЕМАНТИКА БЕЗ СИРОТ // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/colors/test/hotpath-parity.test.mjs b/packages/colors/test/hotpath-parity.test.mjs index be1ebad1..afd4db55 100644 --- a/packages/colors/test/hotpath-parity.test.mjs +++ b/packages/colors/test/hotpath-parity.test.mjs @@ -177,8 +177,9 @@ const hasCompiled = typeof ebg.lerpPairHex === "function" && typeof ebg.lerpPairLuminance === "function"; -/** Reference WCAG relative luminance — a verbatim copy of the string path the - * runtime used pre-optimisation (normative 0.03928/12.92/2.4 constants). */ +/** Reference relative luminance — a verbatim copy of the string path the + * runtime used pre-optimisation (frozen WCAG 2.1 2018 profile: + * 0.03928/12.92/2.4). */ function refLuminance(css) { const rgb = parseCssColor(css) ?? [0, 0, 0, 1]; const lin = (c) => { diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs new file mode 100644 index 00000000..1a7bcfea --- /dev/null +++ b/packages/colors/test/release-contract.test.mjs @@ -0,0 +1,595 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); +const read = (...parts) => readFileSync(join(root, ...parts), "utf8"); + +function workflowNodeScript(workflow, stepName) { + const step = workflow.indexOf(stepName); + assert.ok(step >= 0, `workflow step not found: ${stepName}`); + const marker = "node <<'NODE'\n"; + const start = workflow.indexOf(marker, step); + assert.ok(start >= 0, `node heredoc not found after: ${stepName}`); + const bodyStart = start + marker.length; + const end = workflow.indexOf("\n NODE", bodyStart); + assert.ok(end >= 0, `node heredoc terminator not found after: ${stepName}`); + return workflow + .slice(bodyStart, end) + .split("\n") + .map((line) => line.startsWith(" ") ? line.slice(10) : line) + .join("\n"); +} + +function assertCheckoutCredentialsAreEphemeral(workflow, name) { + const lines = workflow.split("\n"); + const checkouts = lines + .map((line, index) => ({ line, index })) + .filter(({ line }) => line.trimStart().startsWith("- uses: actions/checkout@")); + assert.ok(checkouts.length > 0, `${name} has no checkout steps`); + + for (const { line, index } of checkouts) { + const indentation = line.length - line.trimStart().length; + const step = []; + for (let cursor = index + 1; cursor < lines.length; cursor++) { + const candidate = lines[cursor]; + const candidateIndentation = candidate.length - candidate.trimStart().length; + if (candidate.trim().length > 0 && candidateIndentation <= indentation) break; + step.push(candidate); + } + assert.ok( + step.some((candidate) => candidate.trim() === "persist-credentials: false"), + `${name} checkout at line ${index + 1} persists the workflow token`, + ); + } +} + +test("breaking release metadata is one explicit 0.2.0/0.10.0 contract", () => { + const workspace = read("Cargo.toml"); + assert.match(workspace, /\[workspace\.package\][\s\S]*\nversion = "0\.2\.0"/); + assert.match(workspace, /\nrust-version = "1\.85"/); + assert.match( + workspace, + /repository = "https:\/\/github\.com\/Labpics-Team\/lab-colors"/, + ); + + const packageJson = JSON.parse(read("packages", "colors", "package.json")); + const packageLock = JSON.parse(read("packages", "colors", "package-lock.json")); + assert.equal(packageJson.version, "0.10.0"); + assert.equal(packageJson.packageManager, "npm@11.9.0"); + assert.equal(packageLock.version, "0.10.0"); + assert.equal(packageLock.packages[""].version, "0.10.0"); + assert.equal(packageJson.engines.node, ">=22.11.0"); + assert.equal(packageLock.packages[""].engines.node, ">=22.11.0"); + assert.equal( + packageJson.scripts.prepack, + "npm run build && node ../../scripts/prepare-npm-package.mjs", + ); + assert.match(packageJson.scripts.build, /wasm-pack build .* --locked$/); +}); + +test("every workspace package inherits the declared MSRV", () => { + const manifests = readdirSync(join(root, "crates"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join("crates", entry.name, "Cargo.toml")); + manifests.push(join("experiments", "psychophysics", "Cargo.toml")); + assert.ok(manifests.length > 1, "anti-vacuum: workspace package list is non-trivial"); + for (const manifest of manifests) { + assert.match( + read(manifest), + /^rust-version\.workspace = true$/m, + `${manifest} не публикует/не наследует workspace MSRV`, + ); + } +}); + +test("MSRV and packaged Rust crate gates are executable CI contracts", () => { + assert.ok(existsSync(join(root, "LICENSE")), "root LICENSE отсутствует"); + const coreLicense = join(root, "crates", "labcolors-core", "LICENSE"); + const wasmLicense = join(root, "crates", "labcolors-wasm", "LICENSE"); + for (const license of [coreLicense, wasmLicense]) { + assert.ok(lstatSync(license).isSymbolicLink(), `${license} must preserve the root SSOT`); + assert.equal(readlinkSync(license), "../../LICENSE"); + assert.equal(readFileSync(license, "utf8"), read("LICENSE")); + } + const coreManifest = read("crates", "labcolors-core", "Cargo.toml"); + const coreLib = read("crates", "labcolors-core", "src", "lib.rs"); + assert.match(coreManifest, /^description = "[^"]+"$/m); + assert.match(coreManifest, /^readme = "README\.md"$/m); + assert.match(coreManifest, /^license\.workspace = true$/m); + assert.doesNotMatch(coreManifest, /^license-file\s*=/m); + assert.ok( + existsSync(join(root, "crates", "labcolors-core", "README.md")), + "published core crate needs a package-local README", + ); + assert.match(coreLib, /include_str!\("\.\.\/README\.md"\)/); + assert.doesNotMatch(coreLib, /include_str!\("\.\.\/\.\.\/\.\.\/README\.md"\)/); + + const ci = read(".github", "workflows", "ci.yml"); + assert.match(ci, /^\s*MSRV_TOOLCHAIN: 1\.85\.0$/m); + assert.match(ci, /^\s*NODE_TOOLCHAIN: 24\.14\.0$/m); + assert.match(ci, /^\s*NODE_CONSUMER_FLOOR: 22\.11\.0$/m); + assert.match( + ci, + /^\s*node-consumer-floor:[\s\S]*needs: wasm[\s\S]*node-version: \$\{\{ env\.NODE_CONSUMER_FLOOR \}\}[\s\S]*actions\/download-artifact@[0-9a-f]{40}[\s\S]*--runtime-smoke/m, + ); + assert.match(ci, /^\s*CHROME_FOR_TESTING_VERSION: 150\.0\.7871\.115$/m); + assert.match( + ci, + /^\s*CHROME_FOR_TESTING_SHA256: 1be2db033133c5e2dd1a4e8664bf67b19a61bcf6ed28d2b00f433b3f0b4f9585$/m, + ); + assert.match( + ci, + /^\s*CHROMEDRIVER_FOR_TESTING_SHA256: 6ac3919edd107ca13d08cccc118dc83821877e504014233f171bbd94cb01a80e$/m, + ); + assert.match( + ci, + /actions\/setup-node@[0-9a-f]{40}[\s\S]*node-version: \$\{\{ env\.NODE_TOOLCHAIN \}\}/, + ); + assert.match(ci, /^\s*msrv:$/m); + assert.match(ci, /cargo check --workspace --all-targets --locked/); + assert.match(ci, /cargo package -p labcolors-core --locked/); + assert.match(ci, /test -L crates\/labcolors-core\/LICENSE/); + assert.match(ci, /cmp LICENSE crates\/labcolors-core\/LICENSE/); + assert.match(ci, /tar -xzf .*labcolors-core-\$\{crate_version\}\.crate/); + assert.match(ci, /test ! -L "\$crate_dir\/LICENSE"/); + assert.match(ci, /cmp LICENSE "\$crate_dir\/LICENSE"/); + assert.match( + ci, + /cargo test --doc --manifest-path "\$crate_dir\/Cargo\.toml" --locked/, + ); + assert.match(ci, /id: verified-release[\s\S]*npm run release:verify/); + assert.match(ci, /actions\/upload-artifact@[0-9a-f]{40}[\s\S]*steps\.verified-release\.outputs\.tarball/); + assert.match(ci, /steps\.verified-release\.outputs\.manifest/); + assert.match( + ci, + /name: colors-release-\$\{\{ github\.sha \}\}-attempt-\$\{\{ github\.run_attempt \}\}/, + ); + assert.match(ci, /^\s*include-hidden-files: true$/m); + + const verified = ci.indexOf("id: verified-release"); + const uploaded = ci.indexOf("name: upload verified npm tarball and manifest"); + const browserInstall = ci.indexOf("name: install Chrome + dependencies"); + const browserGate = ci.indexOf("name: wasm-pack test (headless chrome)"); + assert.ok( + verified < uploaded && uploaded < browserInstall && browserInstall < browserGate, + "verified artifact must be uploaded before the pinned Chrome dependency executes", + ); + assert.match(ci, /WASM_PACK_CACHE=\$RUNNER_TEMP\/wasm-pack-\$GITHUB_JOB/); + assert.match( + ci, + /mkdir -p "\$RUNNER_TEMP\/tmp-\$GITHUB_JOB" "\$RUNNER_TEMP\/wasm-pack-\$GITHUB_JOB"/, + ); + assert.doesNotMatch(ci, /chromedriver-bb6facf4ea9511f6|Pre-seeded wasm-pack/); + assert.match(ci, /CHROME_ROOT="\$RUNNER_TEMP\/chrome-\$GITHUB_JOB"/); + assert.match(ci, /DEPS_DIR="\$RUNNER_TEMP\/chrome-deps-\$GITHUB_JOB"/); + assert.match(ci, /CHROME_BIN_DIR="\$RUNNER_TEMP\/chrome-bin-\$GITHUB_JOB"/); + assert.doesNotMatch(ci, /\$HOME|~\//, "WASM/Chrome state must not leak into shared HOME"); + assert.match( + ci, + /printf '%s %s\\n' "\$CHROME_FOR_TESTING_SHA256"[\s\S]*sha256sum --check --strict/, + ); + assert.match( + ci, + /printf '%s %s\\n' "\$CHROMEDRIVER_FOR_TESTING_SHA256"[\s\S]*sha256sum --check --strict/, + ); + assert.ok( + ci.lastIndexOf("sha256sum --check --strict") < ci.indexOf("unzip -q -o"), + "CfT archives must be authenticated before extraction", + ); + assert.doesNotMatch(ci, /last-known-good-versions/); + assert.match( + ci, + /wasm-pack test --headless --chrome --chromedriver "\$CHROMEDRIVER_PATH" crates\/labcolors-wasm --locked/, + ); + assertCheckoutCredentialsAreEphemeral(ci, "CI"); + assertCheckoutCredentialsAreEphemeral( + read(".github", "workflows", "native-conformance.yml"), + "native conformance", + ); + assertCheckoutCredentialsAreEphemeral( + read(".github", "workflows", "mutation.yml"), + "scheduled mutation", + ); + assertCheckoutCredentialsAreEphemeral( + read(".github", "workflows", "publish.yml"), + "publish", + ); +}); + +test("publish accepts only canonical exact-SHA workflow runs and their immutable CI artifact", () => { + const publish = read(".github", "workflows", "publish.yml"); + assert.match(publish, /^\s*NODE_TOOLCHAIN: "24\.14\.0"$/m); + assert.match(publish, /^\s*NPM_TOOLCHAIN: "11\.9\.0"$/m); + assert.match( + publish, + /^concurrency:\n group: npm-publish\n cancel-in-progress: false$/m, + ); + assert.match(publish, /permissions:\n contents: read\n actions: read\n/); + assert.doesNotMatch(publish, /^\s*checks:/m); + assert.doesNotMatch(publish, /id-token:/); + assert.doesNotMatch(publish, /Trusted Publishing|OIDC/); + assert.equal( + [...publish.matchAll(/secrets\.NPM_TOKEN/g)].length, + 1, + "granular npm publish token must be scoped to one step", + ); + assert.match( + publish, + /- name: npm publish verified CI tarball \(granular token, no rebuild\/repack\)[\s\S]*?env:\s*\n\s*NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}[\s\S]*?run: npm publish --ignore-scripts/, + ); + assert.match(publish, /TMPDIR=\$RUNNER_TEMP\/tmp-\$GITHUB_JOB/); + assert.doesNotMatch(publish, /RUSTUP_HOME|CARGO_HOME|RUST_TOOLCHAIN/); + assert.doesNotMatch(publish, /Swatinem\/rust-cache/); + assert.doesNotMatch(publish, /^\s*cache: npm$/m); + + const requiredChecks = [ + "Node 22 consumer floor", + "MSRV workspace check", + "clippy + rustfmt", + "cargo doc (intra-doc links)", + "test", + "cargo audit (rustsec)", + "wasm build + headless test + size", + "docs-drift (нейминг-канон)", + "swift conformance (self-hosted Linux, swift container)", + ]; + for (const check of requiredChecks) { + assert.ok(publish.includes(JSON.stringify(check)), `publish gate lost required check ${check}`); + } + assert.match(publish, /file: "ci\.yml"[\s\S]*path: "\.github\/workflows\/ci\.yml"/); + assert.match( + publish, + /file: "native-conformance\.yml"[\s\S]*path: "\.github\/workflows\/native-conformance\.yml"/, + ); + assert.match(publish, /head_sha: expectedSha/); + assert.match(publish, /branch: "main"/); + assert.match(publish, /event: "push"/); + assert.match(publish, /run\.path === spec\.path/); + assert.match(publish, /run\.head_sha === expectedSha/); + assert.match(publish, /Number\(right\.id\) - Number\(left\.id\)/); + assert.match(publish, /run\.status === "completed" && run\.conclusion === "success"/); + assert.match(publish, /\/actions\/runs\/\$\{run\.id\}\/jobs\?filter=latest/); + assert.match(publish, /Number\(job\.run_id\) === Number\(run\.id\)/); + assert.doesNotMatch(publish, /check-runs|\/commits\/.*\/checks/); + + assert.match(publish, /node-version: \$\{\{ env\.NODE_TOOLCHAIN \}\}/); + assert.match( + publish, + /actions\/download-artifact@[0-9a-f]{40}[\s\S]*name: colors-release-\$\{\{ github\.sha \}\}-attempt-\$\{\{ steps\.canonical-runs\.outputs\.ci_run_attempt \}\}[\s\S]*run-id: \$\{\{ steps\.canonical-runs\.outputs\.ci_run_id \}\}/, + ); + assert.match(publish, /outputs\.push\(`ci_run_attempt=\$\{run\.run_attempt\}`\)/); + assert.match(publish, /manifest\.sourceSha !== expectedSha/); + assert.match(publish, /manifest\.npm !== expectedVersion/); + assert.match(publish, /process\.versions\.node !== expectedNode/); + assert.match(publish, /execFileSync\("npm", \["--version"\]/); + assert.match(publish, /npmVersion !== expectedNpm/); + assert.match(publish, /manifest\.artifacts\?\.tarball/); + assert.match(publish, /evidence\.bytes !== bytes\.length/); + assert.match(publish, /createHash\("sha256"\)\.update\(bytes\)\.digest\("hex"\)/); + assert.match(publish, /packedPackage\.name !== "@labpics\/colors"/); + assert.match( + publish, + /TARBALL_PATH: \$\{\{ steps\.verified-artifact\.outputs\.tarball \}\}[\s\S]*run: npm publish --ignore-scripts "\$TARBALL_PATH"/, + ); + assert.doesNotMatch(publish, /wasm-pack|npm ci|release:verify|actions\/upload-artifact|npm pack/); + + const downloaded = publish.indexOf("name: download immutable exact-SHA release artifact"); + const exactNode = publish.indexOf("actions/setup-node@"); + const validated = publish.indexOf("id: verified-artifact"); + const token = publish.indexOf("NODE_AUTH_TOKEN:"); + const canonicalGuard = publish.indexOf("id: canonical-runs"); + assert.ok( + canonicalGuard >= 0 && + canonicalGuard < downloaded && + downloaded < exactNode && + exactNode < validated && + validated < token, + "network toolchain setup must happen only after canonical-run and artifact gates", + ); +}); + +test("canonical-run guard executes against workflow-scoped runs and jobs", () => { + const publish = read(".github", "workflows", "publish.yml"); + const selector = workflowNodeScript( + publish, + "name: guard — canonical exact-SHA workflow runs and their own jobs", + ); + const requiredCiJobs = [ + "Node 22 consumer floor", + "MSRV workspace check", + "clippy + rustfmt", + "cargo doc (intra-doc links)", + "test", + "cargo audit (rustsec)", + "wasm build + headless test + size", + "docs-drift (нейминг-канон)", + ]; + assert.ok(requiredCiJobs.length > 5, "anti-vacuum: CI gate list is unexpectedly small"); + + const expectedSha = "a".repeat(40); + const successfulJob = (name, runId) => ({ + name, + run_id: runId, + status: "completed", + conclusion: "success", + }); + const fixtures = { + ciRuns: [ + { + id: 999, + path: ".github/workflows/not-ci.yml", + head_sha: expectedSha, + head_branch: "main", + event: "push", + status: "completed", + conclusion: "success", + }, + { + id: 101, + run_attempt: 3, + path: ".github/workflows/ci.yml", + head_sha: expectedSha, + head_branch: "main", + event: "push", + status: "completed", + conclusion: "success", + }, + ], + nativeRuns: [ + { + id: 202, + run_attempt: 1, + path: ".github/workflows/native-conformance.yml", + head_sha: expectedSha, + head_branch: "main", + event: "push", + status: "completed", + conclusion: "success", + }, + ], + ciJobs: [ + ...requiredCiJobs.map((name) => successfulJob(name, 101)), + successfulJob(requiredCiJobs[0], 999), + ], + nativeJobs: [ + successfulJob("swift conformance (self-hosted Linux, swift container)", 202), + ], + }; + const fetchHarness = ` + const fixtures = JSON.parse(process.env.FETCH_FIXTURES); + global.fetch = async (input) => { + const path = new URL(String(input)).pathname; + let payload; + if (path.endsWith("/actions/workflows/ci.yml/runs")) { + payload = { workflow_runs: fixtures.ciRuns }; + } else if (path.endsWith("/actions/workflows/native-conformance.yml/runs")) { + payload = { workflow_runs: fixtures.nativeRuns }; + } else if (path.endsWith("/actions/runs/101/jobs")) { + payload = { jobs: fixtures.ciJobs }; + } else if (path.endsWith("/actions/runs/202/jobs")) { + payload = { jobs: fixtures.nativeJobs }; + } else { + return new Response("unexpected API path", { status: 404 }); + } + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + `; + + const temporary = mkdtempSync(join(tmpdir(), "labcolors-run-gate-")); + try { + const output = join(temporary, "github-output"); + const execute = (value) => { + writeFileSync(output, ""); + return execFileSync(process.execPath, ["-e", `${fetchHarness}\n${selector}`], { + env: { + ...process.env, + FETCH_FIXTURES: JSON.stringify(value), + GH_READ_TOKEN: "test-token", + GITHUB_API_URL: "https://api.github.test", + GITHUB_OUTPUT: output, + GITHUB_REPOSITORY: "Labpics-Team/lab-colors", + GITHUB_SHA: expectedSha, + }, + encoding: "utf8", + stdio: "pipe", + }); + }; + + execute(fixtures); + assert.equal( + readFileSync(output, "utf8"), + "ci_run_id=101\nci_run_attempt=3\nnative_run_id=202\n", + ); + + const wrongRunJobs = structuredClone(fixtures); + wrongRunJobs.ciJobs = wrongRunJobs.ciJobs.filter((job) => + !(job.name === requiredCiJobs[0] && job.run_id === 101)); + assert.throws(() => execute(wrongRunJobs), /Command failed/u); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +}); + +test("publish artifact validator executes and rejects identity or byte drift", () => { + const publish = read(".github", "workflows", "publish.yml"); + const validator = workflowNodeScript( + publish, + "name: validate manifest identity and byte-exact tarball", + ); + assert.ok(validator.length > 1_000, "anti-vacuum: extracted validator is unexpectedly small"); + + const temporary = mkdtempSync(join(tmpdir(), "labcolors-publish-contract-")); + try { + const artifact = join(temporary, "artifact"); + const payload = join(temporary, "payload", "package"); + mkdirSync(artifact, { recursive: true }); + mkdirSync(payload, { recursive: true }); + writeFileSync( + join(payload, "package.json"), + `${JSON.stringify({ name: "@labpics/colors", version: "0.10.0" })}\n`, + ); + + const tarball = join(artifact, "labpics-colors-0.10.0.tgz"); + execFileSync("tar", ["-czf", tarball, "-C", join(temporary, "payload"), "package"]); + const bytes = readFileSync(tarball); + const expectedSha = "a".repeat(40); + const manifest = { + schemaVersion: 1, + npm: "0.10.0", + sourceSha: expectedSha, + artifacts: { + tarball: { + path: ".release/labpics-colors-0.10.0.tgz", + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }; + const manifestPath = join(artifact, "release-manifest.json"); + const output = join(temporary, "github-output"); + const fakeBin = join(temporary, "bin"); + mkdirSync(fakeBin); + writeFileSync(join(fakeBin, "npm"), "#!/bin/sh\nprintf '11.9.0\\n'\n"); + chmodSync(join(fakeBin, "npm"), 0o755); + + const execute = () => execFileSync(process.execPath, ["-e", validator], { + env: { + ...process.env, + ARTIFACT_DIR: artifact, + EXPECTED_SHA: expectedSha, + EXPECTED_TAG: "colors-v0.10.0", + EXPECTED_NODE: process.versions.node, + EXPECTED_NPM: "11.9.0", + GITHUB_OUTPUT: output, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }, + encoding: "utf8", + stdio: "pipe", + }); + + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + execute(); + assert.equal(readFileSync(output, "utf8"), `tarball=${tarball}\n`); + + manifest.sourceSha = "b".repeat(40); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws(execute, /Command failed/u); + + manifest.sourceSha = expectedSha; + manifest.artifacts.tarball.sha256 = "0".repeat(64); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws(execute, /Command failed/u); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +}); + +test("release verifier performs an independent byte-for-byte reproduction pass", () => { + const verifier = read("scripts", "verify-package-release.mjs"); + assert.match(verifier, /reproducibility/); + assert.match(verifier, /byteIdentical: true/); + assert.match(verifier, /const npmVersion = lockedNpmVersion\(packageJson\)/); + assert.match(verifier, /consumerRuntime: \{/); + assert.match(verifier, /verifiedFloor: consumerNodeFloor/); + assert.match(verifier, /canonicalGate: "Node 22 consumer floor"/); + assert.match(verifier, /buildToolchain: \{/); + assert.match(verifier, /node: process\.versions\.node/); + assert.match(verifier, /GITHUB_OUTPUT/); + assert.match(verifier, /familySetSha256: sha256\(Buffer\.concat\(familyBuffers\)\)/); + assert.match(verifier, /sha256: sha256\(familyBuffers\[index\]\)/); + assert.match(verifier, /numericalSites: conformance\.numericalSites/); + assert.doesNotMatch( + verifier, + /numericalSites:\s*\[\s*\{/, + "release manifest must copy the generated conformance registry, not duplicate it", + ); +}); + +test("published build metadata binds source, conformance, and WASM inputs", () => { + const packageJson = JSON.parse(read("packages", "colors", "package.json")); + assert.equal( + packageJson.exports["./build-metadata.json"], + "./build-metadata.json", + "build metadata must be a consumer-visible subpath export", + ); + assert.ok(packageJson.files.includes("build-metadata.json")); + + const prepare = read("scripts", "prepare-npm-package.mjs"); + assert.match(prepare, /import \{ workspaceVersion \} from "\.\/cargo-workspace\.mjs";/); + assert.match(prepare, /const BUILD_METADATA = resolve\(PACKAGE_DIR, "build-metadata\.json"\)/); + assert.match(prepare, /sourceSha/); + assert.ok( + prepare.indexOf("const sourceSha = verifiedSourceSha()") < + prepare.indexOf("atomicWrite(PACKED_LICENSE"), + "source guard must run before generated packing inputs are written", + ); + assert.match(prepare, /--porcelain=v1/); + assert.match(prepare, /--untracked-files=normal/); + assert.match(prepare, /GITHUB_SHA .* does not equal checked-out HEAD/); + assert.match(prepare, /coreVersion/); + assert.match(prepare, /packVersion: conformance\.packVersion/); + assert.match(prepare, /packDigest: conformance\.packDigest/); + assert.match(prepare, /manifestSha256: sha256\(Buffer\.from\(conformanceSource\)\)/); + assert.match(prepare, /familySetSha256: sha256\(Buffer\.concat\(familyBytes\)\)/); + assert.match(prepare, /wasm: \{ bytes: wasm\.length, sha256: sha256\(wasm\) \}/); + + const verifier = read("scripts", "verify-package-release.mjs"); + assert.match(verifier, /import \{ workspaceVersion \} from "\.\/cargo-workspace\.mjs";/); + assert.match(verifier, /function validateBuildMetadata/); + assert.match(verifier, /isDeepStrictEqual\(metadata, expected\)/); + assert.match(verifier, /require\.resolve\("@labpics\/colors\/build-metadata\.json"\)/); + assert.match(verifier, /installedBuildMetadata/); + assert.match(verifier, /isDeepStrictEqual\(installedBuildMetadata, expectedBuildMetadata\)/); + assert.match(verifier, /"--offline"/); + assert.match(verifier, /node_modules\/typescript\/package\.json/); + assert.doesNotMatch(verifier, /`typescript@\$\{typescriptVersion\}`/); + assert.match(verifier, /artifacts: \{ tarball, wasm, buildMetadata \}/); +}); + +test("package root curates every custom schema/result type without exporting wasm ABI", () => { + const wasmSource = read("crates", "labcolors-wasm", "src", "lib.rs"); + const customSection = wasmSource.match( + /const TS_RESULT_TYPES: &'static str = r##"([\s\S]*?)"##;/u, + )?.[1]; + assert.ok(customSection, "custom TypeScript section not found"); + const generatedNames = [ + ...customSection.matchAll(/^export\s+(?:type|interface)\s+([A-Za-z][A-Za-z0-9_]*)/gmu), + ].map((match) => match[1]); + assert.ok(generatedNames.length > 10, "anti-vacuum: custom type surface is non-trivial"); + assert.equal(new Set(generatedNames).size, generatedNames.length, "duplicate custom type name"); + + const rootTypes = read("packages", "colors", "index.d.ts").match( + /export type \{([\s\S]*?)\} from "\.\/pkg\/labcolors\.js";/u, + )?.[1]; + assert.ok(rootTypes, "curated root type export block not found"); + const exportedNames = [...rootTypes.matchAll(/^\s{2}([A-Za-z][A-Za-z0-9_]*),$/gmu)].map( + (match) => match[1], + ); + assert.deepEqual( + [...exportedNames].sort(), + [...generatedNames].sort(), + "root types must equal the custom public section exactly", + ); + assert.doesNotMatch(rootTypes, /InitOutput|__wbg_/u, "raw wasm ABI must stay private"); +}); diff --git a/packages/colors/test/release-provenance.test.mjs b/packages/colors/test/release-provenance.test.mjs new file mode 100644 index 00000000..e0fbe77e --- /dev/null +++ b/packages/colors/test/release-provenance.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); + +function command(name, args, cwd) { + return execFileSync(name, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +test("prepack source guard is clean-tree and exact-SHA executable evidence", async () => { + const fixture = mkdtempSync(join(tmpdir(), "labcolors-prepack-source-")); + const scripts = join(fixture, "scripts"); + mkdirSync(scripts); + copyFileSync( + join(root, "scripts", "prepare-npm-package.mjs"), + join(scripts, "prepare-npm-package.mjs"), + ); + copyFileSync( + join(root, "scripts", "cargo-workspace.mjs"), + join(scripts, "cargo-workspace.mjs"), + ); + try { + command("git", ["init", "--quiet"], fixture); + command("git", ["config", "user.name", "Lab Colors release test"], fixture); + command("git", ["config", "user.email", "release-test@example.invalid"], fixture); + writeFileSync(join(fixture, "tracked.txt"), "canonical\n"); + command("git", ["add", "."], fixture); + command("git", ["commit", "--quiet", "-m", "fixture"], fixture); + + const previousSha = process.env.GITHUB_SHA; + try { + delete process.env.GITHUB_SHA; + const module = await import(pathToFileURL(join(scripts, "prepare-npm-package.mjs"))); + const head = command("git", ["rev-parse", "HEAD"], fixture); + assert.equal(module.verifiedSourceSha(), head); + + process.env.GITHUB_SHA = head; + assert.equal(module.verifiedSourceSha(), head); + + process.env.GITHUB_SHA = "0".repeat(40); + assert.throws( + () => module.verifiedSourceSha(), + /does not equal checked-out HEAD/, + ); + + delete process.env.GITHUB_SHA; + writeFileSync(join(fixture, "untracked.txt"), "must make the guard red\n"); + assert.throws(() => module.verifiedSourceSha(), /source is dirty/); + } finally { + if (previousSha === undefined) delete process.env.GITHUB_SHA; + else process.env.GITHUB_SHA = previousSha; + } + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("workspace version parser is bounded to the workspace.package table", async () => { + const { workspaceVersion } = await import( + pathToFileURL(join(root, "scripts", "cargo-workspace.mjs")) + ); + assert.equal( + workspaceVersion(` +[package] +version = "9.9.9" + +[workspace.package] +edition = "2024" +version = "0.2.0" + +[workspace.metadata.release] +version = "8.8.8" +`), + "0.2.0", + ); + assert.equal( + workspaceVersion("[workspace.package]\r\nedition = \"2024\"\r\nversion = \"0.3.0\"\r\n"), + "0.3.0", + ); + assert.throws( + () => workspaceVersion(` +[workspace.package] +edition = "2024" + +[workspace.metadata.release] +version = "8.8.8" +`), + /workspace core version is absent/u, + ); + assert.throws( + () => workspaceVersion(` +[workspace.package] +edition = "2024" + +[[example]] +version = "7.7.7" +`), + /workspace core version is absent/u, + ); +}); + +test("release command wrapper terminates a child that hangs past its bound", async () => { + const { command, RELEASE_COMMAND_TIMEOUT_MS } = await import( + pathToFileURL(join(root, "scripts", "verify-package-release.mjs")) + ); + assert.equal(RELEASE_COMMAND_TIMEOUT_MS, 5 * 60 * 1_000); + const fixture = mkdtempSync(join(tmpdir(), "labcolors-release-timeout-")); + try { + const marker = join(fixture, "started"); + const hangingFixture = + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "started\\n");` + + "setInterval(() => {}, 60_000);"; + const startedAt = Date.now(); + assert.throws( + // Node's test runner executes files concurrently. Give the minimum Node + // 22 consumer enough startup headroom under a saturated CI worker; the + // marker still proves the child ran before the bounded timeout fired. + () => command(process.execPath, ["-e", hangingFixture], fixture, { timeoutMs: 2_000 }), + /timed out after 2000 ms/u, + ); + assert.ok(existsSync(marker), "anti-vacuum: the hanging fixture never started"); + assert.ok(Date.now() - startedAt < 8_000, "bounded child exceeded the test deadline"); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("build metadata exact validator rejects one-field tampering", async () => { + const { validateBuildMetadata } = await import( + pathToFileURL(join(root, "scripts", "verify-package-release.mjs")) + ); + const context = { + packageJson: { name: "@labpics/colors", version: "0.10.0" }, + source: "1".repeat(40), + coreVersion: "0.2.0", + conformanceEvidence: { + packVersion: "2.0.0", + packDigest: "64a68cbd", + manifestSha256: "2".repeat(64), + familySetSha256: "3".repeat(64), + }, + wasm: { bytes: 123, sha256: "4".repeat(64) }, + }; + const metadata = { + schemaVersion: 1, + package: { ...context.packageJson }, + sourceSha: context.source, + coreVersion: context.coreVersion, + conformance: { + packVersion: context.conformanceEvidence.packVersion, + packDigest: context.conformanceEvidence.packDigest, + manifestSha256: context.conformanceEvidence.manifestSha256, + familySetSha256: context.conformanceEvidence.familySetSha256, + }, + wasm: { ...context.wasm }, + }; + assert.doesNotThrow(() => validateBuildMetadata(metadata, context)); + + const tampered = structuredClone(metadata); + tampered.wasm.sha256 = "5".repeat(64); + assert.throws( + () => validateBuildMetadata(tampered, context), + /does not exactly bind the release inputs/, + ); +}); + +test("clean-consumer smoke закрепляет коррелированный Glow/Material wire", () => { + const verifier = readFileSync( + join(root, "scripts", "verify-package-release.mjs"), + "utf8", + ); + for (const literal of [ + "layerRecipeProfile", + "appearanceDiagnosticProfile", + "selectionDiagnosticProfile", + "exact-noop-unreachable", + "legacy-reached", + "legacy-unreachable", + "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", + ]) { + assert.match(verifier, new RegExp(literal, "u"), `missing ${literal}`); + } + assert.doesNotMatch(verifier, /\bdiagnosticProfile\b/u); + assert.doesNotMatch(verifier, /targetStatus === "(?:reached|unreachable)"/u); + assert.doesNotMatch(verifier, /outward-interval-v1/u); +}); diff --git a/packages/colors/test/runtime.test.mjs b/packages/colors/test/runtime.test.mjs index 19ebabb9..ad5b621f 100644 --- a/packages/colors/test/runtime.test.mjs +++ b/packages/colors/test/runtime.test.mjs @@ -1,7 +1,7 @@ // Behaviour tests for the framework-free runtime — pure logic + the reactive // controller driven through injected fakes, so they run under plain `node --test` -// with no browser and no WASM. (The WASM↔native parity is covered separately by -// the headless-Chrome `wasm_parity` test.) +// with no browser and no WASM. JS-boundary↔core parity внутри одного wasm runtime +// отдельно проверяет headless-Chrome `wasm_parity` test. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -37,6 +37,88 @@ test("compositeOver is true source-over alpha", () => { assert.equal(r[3], 1); // Fully transparent top → bottom unchanged. assert.deepEqual(compositeOver([9, 9, 9, 0], [40, 50, 60, 1]), [40, 50, 60, 1]); + + // Half-seam из Rust-регрессора: фиксирует тот же порядок binary64-операций, + // чтобы reference-композит и официальный потребитель не разошлись на LSB. + assert.equal(toHex(compositeOver([0, 5, 5, 0.1], [5, 5, 5, 1])), "#050505"); + + // Expanded-форма на этих соседних alpha давала 208→207→208. Affine- + // reference фиксирует другой, объявленный operation order; этот конкретный + // seam характеризуется без утверждения глобальной монотонности. + const centre = 0.812992125984252; + const predecessor = centre - Number.EPSILON / 2; + const successor = centre + Number.EPSILON / 2; + const seam = [predecessor, centre, successor].map( + (alpha) => compositeOver([255, 0, 0, alpha], [1, 0, 0, 1])[0], + ); + assert.ok(seam[0] <= seam[1] && seam[1] <= seam[2], String(seam)); +}); + +test("material alpha rechecks in the declared byte-scale affine legacy-WCAG profile", () => { + // Independent consumer oracle for the Rust material solver. The product's + // versioned profile freezes the original WCAG 2.1 (2018) 0.03928 split; it is + // not the current W3C formula and JS `**` is not a cross-runtime powf proof. + // These fixtures verify the emitted alpha in the declared JS operation order. + const srgbToLinear = (byte) => { + const encoded = byte / 255; + return encoded <= 0.03928 + ? encoded / 12.92 + : ((encoded + 0.055) / 1.055) ** 2.4; + }; + const contrastAgainstWhite = (rgb) => { + const luminance = + 0.2126 * srgbToLinear(rgb[0]) + + 0.7152 * srgbToLinear(rgb[1]) + + 0.0722 * srgbToLinear(rgb[2]); + return 1.05 / (luminance + 0.05); + }; + + for (const { tint, oldAlpha, selectedAlpha, floor } of [ + { + tint: [2, 2, 2], + oldAlpha: 0.41945837958353488, + selectedAlpha: 0.41945837958353827, + floor: 3, + }, + { + tint: [0, 0, 0], + oldAlpha: 0.65080978737170625, + selectedAlpha: 0.65080978737171102, + floor: 7, + }, + ]) { + const bottom = [255, 255, 255, 1]; + const oldContrast = contrastAgainstWhite(compositeOver([...tint, oldAlpha], bottom)); + const selectedContrast = contrastAgainstWhite( + compositeOver([...tint, selectedAlpha], bottom), + ); + assert.ok(oldContrast < floor, `old ${oldContrast} must miss ${floor}`); + assert.ok(selectedContrast >= floor, `selected ${selectedContrast} must hold ${floor}`); + } + + // Endpoint-only evaluation used to accept the old alpha exactly at the + // requested floor, while an admissible interior backdrop crossed the + // downward 0.03928 EOTF seam and missed it. The selected alpha comes from the + // conservative channel envelope that includes both seam sides. + const seamFloor = 19.7963; + const oldSeamAlpha = 0.96071066801335769; + const selectedSeamAlpha = 0.96072042755466869; + const interiorByte = 0.9997624803942831 * 255; + const interior = [interiorByte, interiorByte, interiorByte, 1]; + const oldInteriorContrast = contrastAgainstWhite( + compositeOver([0, 0, 0, oldSeamAlpha], interior), + ); + const selectedInteriorContrast = contrastAgainstWhite( + compositeOver([0, 0, 0, selectedSeamAlpha], interior), + ); + assert.ok( + oldInteriorContrast < seamFloor, + `endpoint-only alpha ${oldInteriorContrast} must expose the interior seam miss`, + ); + assert.ok( + selectedInteriorContrast >= seamFloor, + `enveloped alpha ${selectedInteriorContrast} must hold ${seamFloor}`, + ); }); test("toHex rounds and clamps", () => { diff --git a/packages/colors/test/wasm-boundary-parity.test.mjs b/packages/colors/test/wasm-boundary-parity.test.mjs index 74567456..d5486060 100644 --- a/packages/colors/test/wasm-boundary-parity.test.mjs +++ b/packages/colors/test/wasm-boundary-parity.test.mjs @@ -39,6 +39,12 @@ test("wasm recheck boundary is byte-identical to the pre-optimisation golden", a "utf8", ); const golden = JSON.parse(readFileSync(goldenPath, "utf8")); + const metaText = JSON.stringify(golden._meta); + assert.doesNotMatch( + metaText, + /\?{3,}|\uFFFD/u, + "golden provenance не должен содержать следы потери UTF-8", + ); const engine = new LabColors(); engine.loadConfig(CONFIG); diff --git a/packages/colors/test/wasm-boundary.golden.json b/packages/colors/test/wasm-boundary.golden.json index 4bdcef4c..d12dbc3b 100644 --- a/packages/colors/test/wasm-boundary.golden.json +++ b/packages/colors/test/wasm-boundary.golden.json @@ -1 +1 @@ -{"_meta":{"purpose":"Pre-optimisation byte-identity lock for the JS↔WASM recheck boundary (recheckContrast / _recheckContrastMulti) plus a resolveTheme vars snapshot.","regenerated":"2026-07-08","regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","recheck_unchanged":"recheck section: 2752/2752 contrast pairs (lc, wcag) byte-identical. No recheckContrast / contrast value changed. The WCAG relative-luminance + contrast_core + ratio_from_luminances chain is untouched by #185/#186.","numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 89.876): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); hue_vanished flag fires honestly for the achromatic sentiment."},"wcag_floors_not_weakened":"Glow-centre is a decorative FX bloom colour, NOT a text/border/focus-ring role — no WCAG contrast floor or focus-appearance (WCAG 2.4.13) floor applies to it. Every actual contrast value (recheck section, 2752 pairs) is byte-identical, so no measured contrast dropped below any floor. North invariant #2 (accessibility-by-construction) is preserved: measured, not merely asserted.","isolated_verification_required":"Regen accepted only after an isolated adversarial verifier (not seeing the self-claim) confirms RED→GREEN and that no WCAG floor weakened.","drift_scope_wave1":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"1.0000","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"1.0000","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"1.0000","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"1.0000","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65156% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.055)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65156% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.833)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98123% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10085% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.0123","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.0104","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0093","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0059","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(69.23260% 0.013029 286.055)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1070","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1063","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0686","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0433","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.70211% 0.013945 291.371)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.054)","--lab-label-danger-secondary":"oklch(83.38591% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42821% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.0690","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.0584","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0381","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0279","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90400% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96697% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1336","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1787","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0893","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0639","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.31157% 0.013986 291.359)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31782% 0.156989 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1812","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.4300","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.1737","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0995","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(36.40556% 0.013673 291.446)"}}]} \ No newline at end of file +{"_meta":{"purpose":"Байт-точный golden границы JS↔WASM: recheckContrast/_recheckContrastMulti и полный snapshot resolveTheme.vars.","regenerated":"2026-07-10","regen_reason":"ADR-0004: point-glow решается по конечным encoded-sRGB8 screen-state; alpha эмитируется кратчайшей decimal-записью без повторного округления.","drift_scope":"Относительно origin/main изменены ровно 24 листа resolveVars: только --lab-fx-glow-{brand,danger,warning,neutral}-alpha на 6 фонах. Ни один base/core, label, border, fill, background или другой var не изменён.","recheck_unchanged":"Секция recheck: 2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), байт-идентичны origin/main; ни одно значение recheckContrast не изменено.","numeric_provenance":{"glow_alpha":"Старый fixed-48 bisection + 4-decimal alpha заменён перебором конечных sRGB8-state, внутренней alpha выбранного интервала и shortest-roundtrip binary64 CSS.","scope_check":"Фактический структурный diff вычислен по каждому листу resolveVars; 4 alpha-ключа × 6 записей = 24."},"wcag_floors_not_weakened":"Glow — декоративная роль без WCAG-пола. Отдельно подтверждено: все 1376 пар (2752 числовых значения) recheck неизменны, поэтому измеренный контраст ни одной проверяемой роли не ослаблен.","isolated_verification_required":"Регенерация принимается только после независимой проверки scope, recheck-инварианта и полного Rust/npm/browser gate.","partition_correction":{"regenerated":"2026-07-11","reason":"Коррекция канонической binary64 alpha по фактическому midpoint encoded-sRGB8 partition без дополнительного округления.","drift_scope":"Ровно 17 alpha leaves: light/#000000 danger и четыре Glow alpha на каждом из #808080, #3A3A3C, #007AFF, #FF3B30; остальные resolveVars-листья неизменны.","recheck_unchanged":"2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), побитно неизменны.","numeric_provenance":{"halo":"#FF3B30","background":"#000000","state":"#030101","lower_bits":"0x3f85555555555555","upper_bits":"0x3f8c1c1c1c1c1c1c","midpoint_bits":"0x3f88b8b8b8b8b8b8","midpoint":"0.012071078431372548","previous_bits":"0x3f88b8b8b8b8b8b9","previous_relation":"1 ULP выше, внутри той же partition"},"exhaustive_proof":"65 536 channel partitions и seam: state/composite outputs неизменны.","performance":"Full-range → bracketed exact search: base 19.7→4.37 µs, bloom 74.1→16.2 µs, long 656→139 µs."},"history":{"previous_regenerated":"2026-07-10","previous_regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","previous_drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","previous_recheck_unchanged":"Исторический regen также не менял recheck; прежний текст ошибочно называл 2752 числа парами. Фактический объём: 1376 пар (lc, wcag).","previous_numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 89.876): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); hue_vanished flag fires honestly for the achromatic sentiment."},"previous_wave1_scope":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."}},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.5","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.5","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.5","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.5","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65156% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.055)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65156% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.833)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98123% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10085% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.013010286081645773","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.012071078431372548","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.009560345877481427","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.00784313725490196","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(69.23260% 0.013029 286.055)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.11057506131405687","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.11023622047244083","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.07169755954418727","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.04724409448818886","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.70211% 0.013945 291.371)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.054)","--lab-label-danger-secondary":"oklch(83.38591% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42821% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.06909778454881221","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.060913705583756306","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.040609137055837526","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.028061954965508236","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90400% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96697% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1414396647356093","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1805418331471749","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.09073588245366788","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.06430782839451565","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.31157% 0.013986 291.359)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31782% 0.156989 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1835748792270531","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.4395613333149519","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.1777791862086449","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.1016772651089421","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(36.40556% 0.013673 291.446)"}}]} diff --git a/scripts/cargo-workspace.mjs b/scripts/cargo-workspace.mjs new file mode 100644 index 00000000..5f99ceaa --- /dev/null +++ b/scripts/cargo-workspace.mjs @@ -0,0 +1,18 @@ +const WORKSPACE_PACKAGE_HEADER = + /^[ \t]*\[workspace\.package\][ \t]*(?:#.*)?\r?$/mu; +const ANY_TABLE_HEADER = + /^[ \t]*(?:\[[^\[\]\r\n]+\]|\[\[[^\[\]\r\n]+\]\])[ \t]*(?:#.*)?\r?$/mu; +const VERSION_ENTRY = /^[ \t]*version[ \t]*=[ \t]*"([^"\r\n]+)"[ \t]*(?:#.*)?\r?$/mu; + +/** Читает root version, не пересекая границу TOML-таблицы `[workspace.package]`. */ +export function workspaceVersion(cargoSource) { + const header = WORKSPACE_PACKAGE_HEADER.exec(cargoSource); + if (header) { + const remainder = cargoSource.slice(header.index + header[0].length); + const nextTable = remainder.search(ANY_TABLE_HEADER); + const workspacePackage = nextTable < 0 ? remainder : remainder.slice(0, nextTable); + const version = VERSION_ENTRY.exec(workspacePackage)?.[1]; + if (version) return version; + } + throw new Error("workspace core version is absent"); +} diff --git a/scripts/docs-drift.test.mjs b/scripts/docs-drift.test.mjs index 80f74bb9..699a9a22 100644 --- a/scripts/docs-drift.test.mjs +++ b/scripts/docs-drift.test.mjs @@ -7,7 +7,9 @@ */ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; +import { join } from 'node:path'; import { ROOT, @@ -204,3 +206,160 @@ test('auditRepo на текущем дереве: ноль ошибок (док test('collectInventory детерминирован (двойной прогон байт-в-байт)', () => { assert.deepEqual(collectInventory(ROOT), collectInventory(ROOT)); }); + +test('empirical residue: каждая живая policy-константа имеет terminal provenance class', () => { + const text = readFileSync(join(ROOT, 'docs', 'empirical-residue.md'), 'utf8'); + const inventory = readFileSync(join(ROOT, 'docs', 'empirical-inventory.md'), 'utf8'); + const table = text + .split('## Классификация текущих 25 policy-констант')[1] + ?.split('\n**(b) после follow-on')[0]; + assert.ok(table, 'таблица текущей terminal-классификации должна существовать'); + + const rows = table + .split('\n') + .filter((line) => /^\|\s*(?:\*\*)?(?:~~)?\d/.test(line)) + .filter((line) => !line.includes('~~20~~')); + assert.equal(rows.length, 25, 'anti-vacuum: ожидаются ровно 25 живых policy-констант'); + + const counts = new Map([ + ['a', 0], + ['b', 0], + ['c', 0], + ['e', 0], + ]); + for (const row of rows) { + const cells = row.split('|').slice(1, -1).map((cell) => cell.trim()); + const terminal = cells[3]?.match(/\(([abce])\)/)?.[1]; + assert.ok( + terminal, + `строка ${cells[0]} ${cells[1]} смешивает scientific status с terminal class: ${cells[3]}`, + ); + counts.set(terminal, counts.get(terminal) + 1); + } + assert.deepEqual(Object.fromEntries(counts), { a: 6, b: 1, c: 7, e: 11 }); + + for (const gamma of ['NEUTRAL_DEFAULT_GAMMA_LIGHT', 'NEUTRAL_DEFAULT_GAMMA_DARK']) { + const row = rows.find((line) => line.includes(`\`${gamma}\``)); + assert.ok(row, `${gamma} должен присутствовать в таблице`); + assert.match(row, /\*\*\(e\)\*\*/); + assert.match( + row, + /НАУЧНАЯ ЗАМЕНА: OPEN/, + `${gamma}: frozen compatibility provenance и open scientific replacement — разные поля`, + ); + + const inventoryRow = inventory + .split('\n') + .find((line) => line.startsWith('|') && line.includes(`\`${gamma}\``)); + assert.ok(inventoryRow, `${gamma} должен присутствовать в основном инвентаре`); + assert.match(inventoryRow, /\*\*\(e\) DESIGN-CHOICE \/ COMPATIBILITY POLICY\*\*/); + assert.match( + inventoryRow, + /НАУЧНАЯ ЗАМЕНА: OPEN/, + `${gamma}: основной инвентарь обязан разделять terminal provenance и scientific status`, + ); + } +}); + +test('issue-ссылки не превращаются в ATX headings ни в одном docs markdown', () => { + const malformed = collectInventory(ROOT).docsMd.flatMap((path) => + readFileSync(join(ROOT, 'docs', path), 'utf8') + .split('\n') + .map((line, index) => ({ path, line, number: index + 1 })) + .filter(({ line }) => /^#\d/.test(line)), + ); + assert.deepEqual(malformed, []); +}); + +test('breaking exact-alpha/glow контракт имеет migration и не оставляет единый live profile', () => { + const migration = readFileSync( + join(ROOT, 'docs', 'migrations', 'exact-alpha-glow.md'), + 'utf8', + ); + const changelog = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'); + const readme = readFileSync(join(ROOT, 'packages', 'colors', 'README.md'), 'utf8'); + const adr = readFileSync( + join(ROOT, 'docs', 'decisions', '0004-finite-alpha-glow-reference.md'), + 'utf8', + ); + + for (const required of [ + '@labpics/colors` 0.10.0', + 'Rust workspace | 0.1.0 | 0.2.0', + 'decision_profile', + 'stable-v1', + 'legacy-platform-dependent-v1', + 'glow-indeterminate', + 'sound-bound-unavailable', + 'compositeProfile', + 'layerRecipeProfile', + 'appearanceDiagnosticProfile', + 'selectionDiagnosticProfile', + 'resolve_alpha_analog_hex', + 'Rollback', + ]) { + assert.ok(migration.includes(required), `migration не содержит ${required}`); + } + assert.match(changelog, /@labpics\/colors 0\.10\.0 \/ Rust 0\.2\.0/); + assert.doesNotMatch(readme, /referenceProfile/); + assert.doesNotMatch(adr, /referenceProfile/); + assert.doesNotMatch(readme, /\bdiagnosticProfile\b/); + assert.doesNotMatch(adr, /\bdiagnosticProfile\b/); +}); + +test('exact-alpha migration экранирует absolute-value pipes внутри Markdown tables', () => { + const migration = readFileSync( + join(ROOT, 'docs', 'migrations', 'exact-alpha-glow.md'), + 'utf8', + ); + const tableRows = migration.split('\n').filter((line) => line.startsWith('|')); + const malformed = tableRows.filter((line) => /`[^`]*\|ΔJ′\|[^`]*`/.test(line)); + assert.deepEqual(malformed, []); + assert.equal( + tableRows.filter((line) => line.includes('`\\|ΔJ′\\|`')).length, + 3, + 'anti-vacuum: determinate Glow table содержит три |ΔJ′| cells', + ); +}); + +test('legacy muddiness API quarantined as compatibility proxy, not human verdict', () => { + const paths = [ + ['core', 'crates/labcolors-core/src/cleanliness.rs'], + ['wasm', 'crates/labcolors-wasm/src/lib.rs'], + ['ffi', 'crates/labcolors-ffi/src/lib.rs'], + ['conformance docs', 'conformance/README.md'], + ['conformance crate', 'crates/labcolors-conformance/src/lib.rs'], + ['npm docs', 'packages/colors/README.md'], + ['swift docs', 'bindings/swift/README.md'], + ]; + const surfaces = paths.map(([label, path]) => [ + label, + readFileSync(join(ROOT, ...path.split('/')), 'utf8'), + ]); + + for (const [label, source] of surfaces) { + assert.match( + source, + /experimental compatibility proxy/i, + `${label}: legacy API must state its non-observer status`, + ); + } + + const publicText = surfaces.map(([, source]) => source).join('\n'); + for (const forbidden of [ + /Закон Грязи/u, + /Muddiness Law/u, + /0\s*[—-]\s*чистый,\s*1\s*[—-]\s*грязный/u, + /оценка [«"]грязи[»"]/u, + ]) { + assert.doesNotMatch(publicText, forbidden); + } + + const inventory = readFileSync(join(ROOT, 'docs', 'empirical-inventory.md'), 'utf8'); + assert.match(inventory, /M-01[^\n]*Indeterminate provenance/u); + assert.match(inventory, /M-02[^\n]*universal JND Rejected/u); + assert.match(inventory, /M-04[^\n]*Rejected provenance; Indeterminate value/u); + assert.match(inventory, /M-05[^\n]*Rejected provenance; Indeterminate value/u); + assert.match(inventory, /M-12[^\n]*observer claim Rejected/u); + assert.doesNotMatch(inventory, /M-0[45][^\n]*\| cited-measured \|/u); +}); diff --git a/scripts/prepare-npm-package.mjs b/scripts/prepare-npm-package.mjs new file mode 100644 index 00000000..14d0835e --- /dev/null +++ b/scripts/prepare-npm-package.mjs @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { workspaceVersion } from "./cargo-workspace.mjs"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = resolve(SCRIPT_DIR, ".."); +export const PACKAGE_DIR = resolve(REPO_ROOT, "packages/colors"); + +const SOURCE_LICENSE = resolve(REPO_ROOT, "LICENSE"); +const PACKED_LICENSE = resolve(PACKAGE_DIR, "LICENSE"); +const BUILD_METADATA = resolve(PACKAGE_DIR, "build-metadata.json"); +const CONFORMANCE_DIR = resolve(REPO_ROOT, "conformance/vectors"); +const CONFORMANCE_FILES = [ + "contrasts.json", + "ladders.json", + "alpha.json", + "solve.json", + "muddiness.json", +]; + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +function git(args) { + return execFileSync("git", args, { + cwd: REPO_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +/** + * Bind generated packing inputs to one exact, clean checkout. + * + * This guard lives in the prepack helper itself so `npm pack` and the release + * verifier cannot diverge: neither may write plausible build metadata for a dirty + * tree or for a tag SHA different from the checked-out commit. + */ +export function verifiedSourceSha() { + const head = git(["rev-parse", "HEAD"]).toLowerCase(); + const candidate = (process.env.GITHUB_SHA?.trim() || head).toLowerCase(); + if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(candidate)) { + throw new Error(`source SHA is not a full Git object id: ${candidate}`); + } + if (candidate !== head) { + throw new Error(`GITHUB_SHA ${candidate} does not equal checked-out HEAD ${head}`); + } + const changes = git(["status", "--porcelain=v1", "--untracked-files=normal"]); + if (changes) { + throw new Error(`release source is dirty and cannot attest HEAD:\n${changes}`); + } + return head; +} + +async function atomicWrite(path, bytes) { + const temporary = `${path}.tmp-${process.pid}`; + try { + await writeFile(temporary, bytes, { mode: 0o644 }); + await chmod(temporary, 0o644); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +/** + * Copy the repository's canonical licence into the npm package atomically. + * + * `packages/colors/LICENSE` is a generated packing input, not a second source of + * truth. The operation deliberately fails when the root licence is absent or + * empty: publishing an unlicensed tarball is not a recoverable condition. + */ +export async function prepareNpmPackage() { + // Must precede even generated/ignored writes. A rejected call leaves no new + // metadata that could later be packed as if it described the current source. + const sourceSha = verifiedSourceSha(); + const canonical = await readFile(SOURCE_LICENSE); + if (canonical.length === 0) { + throw new Error(`canonical licence is empty: ${SOURCE_LICENSE}`); + } + + await mkdir(PACKAGE_DIR, { recursive: true }); + await atomicWrite(PACKED_LICENSE, canonical); + + const copied = await readFile(PACKED_LICENSE); + if (!copied.equals(canonical)) { + throw new Error("generated npm LICENSE differs from the canonical root LICENSE"); + } + + const [packageJsonSource, cargoSource, conformanceSource, wasm, ...familyBytes] = + await Promise.all([ + readFile(resolve(PACKAGE_DIR, "package.json"), "utf8"), + readFile(resolve(REPO_ROOT, "Cargo.toml"), "utf8"), + readFile(resolve(CONFORMANCE_DIR, "manifest.json"), "utf8"), + readFile(resolve(PACKAGE_DIR, "pkg/labcolors_bg.wasm")), + ...CONFORMANCE_FILES.map((file) => readFile(resolve(CONFORMANCE_DIR, file))), + ]); + const packageJson = JSON.parse(packageJsonSource); + const conformance = JSON.parse(conformanceSource); + const coreVersion = workspaceVersion(cargoSource); + const metadata = { + schemaVersion: 1, + package: { name: packageJson.name, version: packageJson.version }, + sourceSha, + coreVersion, + conformance: { + packVersion: conformance.packVersion, + packDigest: conformance.packDigest, + manifestSha256: sha256(Buffer.from(conformanceSource)), + familySetSha256: sha256(Buffer.concat(familyBytes)), + }, + wasm: { bytes: wasm.length, sha256: sha256(wasm) }, + }; + await atomicWrite(BUILD_METADATA, `${JSON.stringify(metadata, null, 2)}\n`); + + return { license: PACKED_LICENSE, buildMetadata: BUILD_METADATA, sourceSha }; +} + +const invokedDirectly = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + prepareNpmPackage().catch((error) => { + console.error(error instanceof Error ? error.stack : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs new file mode 100644 index 00000000..f58ba336 --- /dev/null +++ b/scripts/verify-package-release.mjs @@ -0,0 +1,989 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + appendFile, + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +import { workspaceVersion } from "./cargo-workspace.mjs"; +import { PACKAGE_DIR, REPO_ROOT, prepareNpmPackage } from "./prepare-npm-package.mjs"; + +const RELEASE_DIR = resolve(PACKAGE_DIR, ".release"); +const RELEASE_MANIFEST = resolve(RELEASE_DIR, "release-manifest.json"); +const PACKAGE_JSON = resolve(PACKAGE_DIR, "package.json"); +const PACKAGE_LOCK = resolve(PACKAGE_DIR, "package-lock.json"); +const BUILD_METADATA = resolve(PACKAGE_DIR, "build-metadata.json"); +const ROOT_CARGO = resolve(REPO_ROOT, "Cargo.toml"); +const CONFORMANCE_DIR = resolve(REPO_ROOT, "conformance/vectors"); +const CONFORMANCE_MANIFEST = resolve(CONFORMANCE_DIR, "manifest.json"); +const CONFORMANCE_FAMILY_FILES = [ + "contrasts.json", + "ladders.json", + "alpha.json", + "solve.json", + "muddiness.json", +]; +const WASM_PATH = resolve(PACKAGE_DIR, "pkg/labcolors_bg.wasm"); + +const REQUIRED_PACK_FILES = ["package.json", "README.md", "LICENSE"]; +const FORBIDDEN_PACK_SEGMENTS = new Set([ + ".git", + ".github", + ".release", + "bench", + "node_modules", + "scripts", + "test", +]); + +function fail(message) { + throw new Error(message); +} + +export const RELEASE_COMMAND_TIMEOUT_MS = 5 * 60 * 1_000; + +export function command( + commandName, + args, + cwd = REPO_ROOT, + { timeoutMs = RELEASE_COMMAND_TIMEOUT_MS } = {}, +) { + try { + return execFileSync(commandName, args, { + cwd, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: timeoutMs, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const stderr = error?.stderr?.toString().trim(); + const stdout = error?.stdout?.toString().trim(); + const detail = [stderr, stdout].filter(Boolean).join("\n"); + const outcome = + error?.code === "ETIMEDOUT" ? `timed out after ${timeoutMs} ms` : "failed"; + fail(`${commandName} ${args.join(" ")} ${outcome}${detail ? `:\n${detail}` : ""}`); + } +} + +function npm(args, cwd = REPO_ROOT) { + // npm exposes the exact CLI entrypoint to lifecycle scripts. Falling back to + // PATH keeps direct `node scripts/verify-package-release.mjs` usable after a + // normal Node installation, while a missing npm remains a hard failure. + if (process.env.npm_execpath) { + return command(process.execPath, [process.env.npm_execpath, ...args], cwd); + } + return command(process.platform === "win32" ? "npm.cmd" : "npm", args, cwd); +} + +async function readJson(path) { + const source = await readFile(path, "utf8"); + try { + return JSON.parse(source); + } catch (error) { + fail(`${relative(REPO_ROOT, path)} is not valid JSON: ${error.message}`); + } +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function hashedArtifact(path, displayPath) { + const bytes = await readFile(path); + if (bytes.length === 0) fail(`${displayPath} is empty`); + return { path: displayPath, bytes: bytes.length, sha256: sha256(bytes) }; +} + +export function validateBuildMetadata( + metadata, + { packageJson, source, coreVersion, conformanceEvidence, wasm }, +) { + const expected = { + schemaVersion: 1, + package: { name: packageJson.name, version: packageJson.version }, + sourceSha: source, + coreVersion, + conformance: { + packVersion: conformanceEvidence.packVersion, + packDigest: conformanceEvidence.packDigest, + manifestSha256: conformanceEvidence.manifestSha256, + familySetSha256: conformanceEvidence.familySetSha256, + }, + wasm: { bytes: wasm.bytes, sha256: wasm.sha256 }, + }; + if (!isDeepStrictEqual(metadata, expected)) { + fail( + "generated build-metadata.json does not exactly bind the release inputs:\n" + + `expected ${JSON.stringify(expected)}\n` + + `actual ${JSON.stringify(metadata)}`, + ); + } +} + +function normalisePackPath(path) { + const normal = path.replaceAll("\\", "/").replace(/^\.\//u, ""); + if (!normal || normal.startsWith("../") || normal.includes("/../")) { + fail(`unsafe path reported by npm pack: ${path}`); + } + return normal; +} + +function exportTargets(value, into = []) { + if (typeof value === "string") { + if (value.startsWith("./")) into.push(value.slice(2)); + return into; + } + if (value && typeof value === "object") { + for (const child of Object.values(value)) exportTargets(child, into); + } + return into; +} + +function validatePackedFiles(packageJson, packResult) { + if (!Array.isArray(packResult.files) || packResult.files.length === 0) { + fail("npm pack did not report a non-empty files inventory"); + } + + const actual = packResult.files.map((entry) => normalisePackPath(entry.path)); + const duplicates = actual.filter((path, index) => actual.indexOf(path) !== index); + if (duplicates.length > 0) fail(`npm pack reported duplicate paths: ${duplicates.join(", ")}`); + + const expected = new Set([ + ...REQUIRED_PACK_FILES, + ...(packageJson.files ?? []).map(normalisePackPath), + ]); + for (const target of exportTargets(packageJson.exports)) expected.add(normalisePackPath(target)); + if (typeof packageJson.types === "string") expected.add(normalisePackPath(packageJson.types)); + + const actualSet = new Set(actual); + const missing = [...expected].filter((path) => !actualSet.has(path)).sort(); + if (missing.length > 0) fail(`npm tarball is missing required files: ${missing.join(", ")}`); + + const undeclared = actual.filter((path) => !expected.has(path)).sort(); + if (undeclared.length > 0) { + fail(`npm tarball contains undeclared files: ${undeclared.join(", ")}`); + } + + for (const path of actual) { + const segments = path.split("/"); + const forbidden = segments.find((segment) => FORBIDDEN_PACK_SEGMENTS.has(segment)); + if (forbidden) fail(`npm tarball contains forbidden ${forbidden} path: ${path}`); + } +} + +async function packInto(destination, packageJson) { + await mkdir(destination, { recursive: true }); + const packedJson = npm( + ["pack", "--ignore-scripts", "--json", `--pack-destination=${destination}`, PACKAGE_DIR], + REPO_ROOT, + ).trim(); + let packed; + try { + packed = JSON.parse(packedJson); + } catch (error) { + fail(`npm pack --json returned invalid JSON: ${error.message}`); + } + if (!Array.isArray(packed) || packed.length !== 1) { + fail(`npm pack returned ${Array.isArray(packed) ? packed.length : "non-array"} results`); + } + + const packResult = packed[0]; + if (packResult.name !== packageJson.name || packResult.version !== packageJson.version) { + fail( + `npm pack produced ${packResult.name}@${packResult.version}, ` + + `expected ${packageJson.name}@${packageJson.version}`, + ); + } + const tarballName = basename(packResult.filename ?? ""); + if (!tarballName.endsWith(".tgz") || tarballName !== packResult.filename) { + fail(`npm pack returned an unsafe tarball filename: ${packResult.filename}`); + } + validatePackedFiles(packageJson, packResult); + + return { path: resolve(destination, tarballName), tarballName }; +} + +function fnv1a32(buffers) { + let hash = 0x811c9dc5; + for (const buffer of buffers) { + for (const byte of buffer) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + } + return hash.toString(16).padStart(8, "0"); +} + +async function validateConformance(conformance) { + if (conformance.packVersion !== "2.0.0") { + fail(`release requires conformance pack 2.0.0, got ${conformance.packVersion}`); + } + if (!/^[0-9a-f]{8}$/u.test(conformance.packDigest ?? "")) { + fail(`invalid conformance packDigest: ${conformance.packDigest}`); + } + + const familyBuffers = await Promise.all( + CONFORMANCE_FAMILY_FILES.map((name) => readFile(resolve(CONFORMANCE_DIR, name))), + ); + const actualDigest = fnv1a32(familyBuffers); + if (actualDigest !== conformance.packDigest) { + fail( + `conformance packDigest ${conformance.packDigest} does not bind family bytes ${actualDigest}`, + ); + } + + const families = familyBuffers.map((bytes, index) => { + try { + const value = JSON.parse(bytes.toString("utf8")); + if (!Array.isArray(value) || value.length === 0) { + fail(`${CONFORMANCE_FAMILY_FILES[index]} must contain a non-empty vector array`); + } + return value; + } catch (error) { + fail(`${CONFORMANCE_FAMILY_FILES[index]} is not valid JSON: ${error.message}`); + } + }); + const countKeys = ["contrasts", "ladders", "alpha", "solve", "muddiness"]; + let total = 0; + for (const [index, key] of countKeys.entries()) { + const actual = families[index].length; + if (conformance.counts?.[key] !== actual) { + fail(`conformance count ${key}=${conformance.counts?.[key]} differs from ${actual}`); + } + total += actual; + } + if (conformance.counts?.total !== total) { + fail(`conformance total=${conformance.counts?.total} differs from ${total}`); + } + const halfTie = families[2].find( + (entry) => entry.tint === "#C0B2FA" && entry.bg === "#000000" && entry.alpha === 0.122, + ); + if (halfTie?.composite !== "#17161F") { + fail("conformance pack lacks the exact source-over half-tie #C0B2FA@0.122 -> #17161F"); + } + if (!Array.isArray(conformance.numericalSites) || conformance.numericalSites.length === 0) { + fail("conformance manifest has no generated numericalSites registry"); + } + const glowSite = conformance.numericalSites.find( + (site) => site.siteId === "glow-target-or-maximum-v1", + ); + if ( + JSON.stringify(glowSite?.stableOutcomes) !== + JSON.stringify(["bit-exact", "indeterminate"]) || + glowSite?.boundStatus !== "unavailable" || + glowSite?.fallbackStatus !== "none" || + glowSite?.legacyProfile !== "legacy-platform-dependent-v1" + ) { + fail("generated Glow numerical registry does not describe both lawful stable outcomes"); + } + + const manifestBytes = await readFile(CONFORMANCE_MANIFEST); + return { + packVersion: conformance.packVersion, + packDigest: conformance.packDigest, + counts: conformance.counts, + manifestSha256: sha256(manifestBytes), + familySetSha256: sha256(Buffer.concat(familyBuffers)), + families: CONFORMANCE_FAMILY_FILES.map((path, index) => ({ + path: `conformance/vectors/${path}`, + bytes: familyBuffers[index].length, + sha256: sha256(familyBuffers[index]), + })), + }; +} + +function lockedTypescriptVersion(packageLock) { + const version = packageLock.packages?.["node_modules/typescript"]?.version; + if (!/^\d+\.\d+\.\d+(?:[-+].+)?$/u.test(version ?? "")) { + fail("package-lock.json does not pin an exact TypeScript compiler version"); + } + return version; +} + +function lockedNpmVersion(packageJson) { + const declared = packageJson.packageManager?.match(/^npm@(\d+\.\d+\.\d+)$/u)?.[1]; + if (!declared) { + fail("package.json packageManager must pin an exact npm version"); + } + const actual = npm(["--version"]).trim(); + if (actual !== declared) { + fail(`release packer is npm ${actual}, expected packageManager npm@${declared}`); + } + return declared; +} + +function runtimeSmokeSource() { + return String.raw` +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; + +import init, { LabColors } from "@labpics/colors"; + +const require = createRequire(import.meta.url); +const wasmPath = require.resolve("@labpics/colors/pkg/labcolors_bg.wasm"); +const metadataPath = require.resolve("@labpics/colors/build-metadata.json"); +const packagePath = require.resolve("@labpics/colors/package.json"); +const metadata = JSON.parse(await readFile(metadataPath, "utf8")); +const installedPackage = JSON.parse(await readFile(packagePath, "utf8")); +assert.deepEqual(metadata.package, { + name: installedPackage.name, + version: installedPackage.version, +}); +assert.match(metadata.sourceSha, /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u); +assert.match(metadata.coreVersion, /^\d+\.\d+\.\d+$/u); +assert.equal(metadata.wasm.bytes, (await readFile(wasmPath)).length); +await init({ module_or_path: await readFile(wasmPath) }); + +const config = { + brand: { + light: "#17161F", + dark: "#17161F", + light_ic: "#17161F", + dark_ic: "#17161F", + }, + neutral: { + anchors: { light: "#FFFFFF", mid: "#7A7A82", dark: "#17171A" }, + tint: { ratio: 0.1, target_mp: 6.1, hue_stiffness: 9.0 }, + }, + palette: [{ + key: "family-4d", + anchors: { + light: "#7C3AED", + dark: "#8B5CF6", + light_ic: "#5B21B6", + dark_ic: "#A78BFA", + }, + }], + sentiments: { categories: [], hardness: 5.0, chroma_fraction: 0.88 }, + themes: [{ name: "light", preset: "srgb" }], + roles: [ + { + name: "token-7f3a", + recipe: { kind: "alpha-analog", of: { kind: "brand" }, alpha: 0.122 }, + }, + { + name: "token-92be", + recipe: { + kind: "glow", + source: { kind: "family", key: "family-4d" }, + step: "base", + decision_profile: "legacy-platform-dependent-v1", + }, + }, + { + name: "token-a11c", + recipe: { + kind: "material", + source: { kind: "brand" }, + tone_light: 0.72, + tone_dark: 0.28, + floor: "aa-ui", + }, + }, + ], +}; + +const bytes = (hex) => [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16)); +const hex = (channels) => "#" + channels.map((channel) => channel.toString(16).padStart(2, "0")).join("").toUpperCase(); +const sourceOver = (tintHex, alpha, bgHex) => { + const tint = bytes(tintHex); + const bg = bytes(bgHex); + return hex(bg.map((channel, index) => Math.round(channel + alpha * (tint[index] - channel)))); +}; +const screen = (glowHex, alpha, bgHex) => { + const glow = bytes(glowHex); + const bg = bytes(bgHex); + return hex(bg.map((channel, index) => + Math.round(channel + alpha * glow[index] * (255 - channel) / 255) + )); +}; + +const engine = new LabColors(); +const fingerprint = engine.loadConfig(JSON.stringify(config)); +assert.match(fingerprint, /^[0-9a-f]{16}$/u); + +const background = "#000000"; +const resolved = engine.resolveTheme(background, "light"); +assert.deepEqual(Object.keys(resolved.roles).sort(), ["token-7f3a", "token-92be", "token-a11c"]); + +const alpha = resolved.roles["token-7f3a"]; +assert.equal(alpha.kind, "translucent"); +assert.match(alpha.tintHex, /^#[0-9A-F]{6}$/u); +assert.equal(alpha.alpha, 0.122); +assert.equal(alpha.compositeHex, "#17161F"); +assert.equal(alpha.alphaCoerced, false); +assert.equal(sourceOver(alpha.tintHex, alpha.alpha, background), alpha.compositeHex); +assert.match(alpha.css, /^oklch\(.+ \/ 0\.122\)$/u); + +const material = resolved.roles["token-a11c"]; +assert.equal(material.kind, "material"); +assert.equal( + material.alphaGuarantee.numericalProfile, + "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", +); +if (material.alphaGuarantee.kind === "transparent-endpoint-characterized-v1") { + assert.equal(material.alpha, 0); + assert.equal(material.alphaStatus, "satisfied"); + assert.equal(material.guaranteed, true); +} else if (material.alphaGuarantee.kind === "bisection-bracket-characterized-v1") { + assert.equal(material.alpha, material.alphaGuarantee.upperAlpha); + assert.equal(material.alphaStatus, "satisfied"); + assert.equal(material.guaranteed, true); +} else { + assert.equal(material.alphaGuarantee.kind, "opaque-endpoint-characterized-v1"); + assert.equal(material.alpha, 1); + assert.equal(material.alphaStatus, "degraded"); + assert.equal(material.guaranteed, false); +} +assert.ok(Number.isFinite(material.floor)); + +const glow = resolved.roles["token-92be"]; +assert.equal(glow.kind, "glow"); +assert.equal(glow.compositeProfile, "encoded-srgb8-screen-v1"); +assert.equal(glow.compositeGuarantee, "bit-exact"); +assert.equal(glow.layerRecipeProfile, "cam16-jprime-oklab-cusp-v1"); +assert.equal(glow.appearanceDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); +assert.equal(glow.selectionDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); +assert.equal(glow.decisionProfile, "legacy-platform-dependent-v1"); +assert.deepEqual(glow.decisionGuarantee, { kind: "legacy-platform-dependent-v1" }); +assert.equal(glow.constraintLayer, "halo"); +assert.ok(glow.targetStatus === "legacy-reached" || glow.targetStatus === "legacy-unreachable"); +assert.ok(Number.isFinite(glow.alpha) && glow.alpha > 0 && glow.alpha <= 1); +assert.equal(Number(glow.alphaCss), glow.alpha); +assert.equal(glow.achievedDj, glow.haloAchievedDj); +assert.equal(glow.degraded, glow.targetStatus === "legacy-unreachable"); +assert.equal(screen(glow.haloHex, glow.alpha, background), glow.haloCompositeHex); +assert.equal(screen(glow.coreHex, glow.alpha, background), glow.coreCompositeHex); +for (const value of [glow.targetDj, glow.haloAchievedDj, glow.coreAchievedDj]) { + assert.ok(Number.isFinite(value)); +} + +assert.equal(engine.isStableGlowPointNoop("#010000", "#FE0000"), true); +assert.equal(engine.isStableGlowPointNoop("#800000", "#FE0000"), false); +assert.equal(engine.isStableGlowPointNoop("#001", "fff"), true); + +config.roles[1].recipe.decision_profile = "stable-v1"; +engine.loadConfig(JSON.stringify(config)); +const stableDark = engine.resolveTheme("#101012", "light"); +const stableIndeterminate = stableDark.roles["token-92be"]; +assert.equal(stableIndeterminate.kind, "glow-indeterminate"); +assert.equal(stableIndeterminate.decisionProfile, "stable-v1"); +assert.equal(stableIndeterminate.reason, "sound-bound-unavailable"); +assert.deepEqual(stableIndeterminate.bounds, { kind: "unavailable" }); +for (const key of [ + stableIndeterminate.cssVar, + stableIndeterminate.cssVar + "-core", + stableIndeterminate.cssVar + "-alpha", +]) { + assert.equal(stableDark.vars[key], undefined); +} + +const stableWhite = engine.resolveTheme("#FFFFFF", "light"); +const stableNoop = stableWhite.roles["token-92be"]; +assert.equal(stableNoop.kind, "glow"); +assert.equal(stableNoop.decisionProfile, "stable-v1"); +assert.deepEqual(stableNoop.decisionGuarantee, { kind: "bit-exact" }); +assert.equal(stableNoop.layerRecipeProfile, "cam16-jprime-oklab-cusp-v1"); +assert.equal(stableNoop.appearanceDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); +assert.equal(stableNoop.selectionDiagnosticProfile, null); +assert.equal(stableNoop.targetStatus, "exact-noop-unreachable"); +assert.equal(stableNoop.degraded, true); +assert.equal(stableNoop.haloCompositeHex, "#FFFFFF"); +for (const key of [ + stableNoop.cssVar, + stableNoop.cssVar + "-core", + stableNoop.cssVar + "-alpha", +]) { + assert.equal(typeof stableWhite.vars[key], "string"); +} +`; +} + +function typeSmokeSource() { + return String.raw` +import init, { + LabColors, + type GlowDecisionGuaranteeV1, + type GlowDeterminateRole, + type GlowDeterminateRoleBase, + type GlowRole, + type GlowTargetStatusV1, + type LadderPositionV1, + type MaterialRole, + type MaterialRoleBase, + type NumericalIndeterminacyV1, + type ResolvedTheme, + type ThemeConfig, + type TranslucentRole, +} from "@labpics/colors"; + +const initialise: typeof init = init; +const engine = new LabColors(); +const fingerprint: string = engine.loadConfig("{}"); +const resolved: ResolvedTheme = engine.resolveTheme("#000000", "light"); +const borderPosition: LadderPositionV1 = "border-strong"; +const config: ThemeConfig = { + brand: { + light: "#17161F", + dark: "#17161F", + light_ic: "#17161F", + dark_ic: "#17161F", + }, + neutral: { + anchors: { light: "#FFFFFF", mid: "#7A7A82", dark: "#17171A" }, + tint: { ratio: 0.1, target_mp: 6.1, hue_stiffness: 9.0 }, + }, + palette: [], + sentiments: { categories: [], hardness: 5.0, chroma_fraction: 0.88 }, + themes: [{ name: "light", preset: "srgb" }], + roles: [ + { + name: "label-a1", + recipe: { + kind: "text-anchor", + fraction: 0.62, + floor: "aa-text", + hue: { kind: "brand" }, + }, + }, + { + name: "border-b2", + recipe: { + kind: "ladder", + source: { kind: "brand" }, + position: borderPosition, + floor: "aa-ui", + }, + }, + { + name: "glow-stable", + recipe: { + kind: "glow", + source: { kind: "brand" }, + step: "base", + decision_profile: "stable-v1", + }, + }, + { + name: "glow-legacy", + recipe: { + kind: "glow", + source: { kind: "brand" }, + step: "base", + decision_profile: "legacy-platform-dependent-v1", + }, + }, + ], +}; + +function alphaContract(role: TranslucentRole): readonly [string, number, string, boolean] { + return [role.tintHex, role.alpha, role.compositeHex, role.alphaCoerced] as const; +} + +function glowContract( + role: GlowRole, +): readonly ["indeterminate", "glow-target-or-maximum-v1"] | readonly [ + "encoded-srgb8-screen-v1", + "halo", + GlowTargetStatusV1, + string, + number, +] { + if (role.kind === "glow-indeterminate") { + return ["indeterminate", role.numericalSiteId] as const; + } + return [ + role.compositeProfile, + role.constraintLayer, + role.targetStatus, + role.haloCompositeHex, + role.haloAchievedDj, + ] as const; +} + +function decisionEvidence(guarantee: GlowDecisionGuaranteeV1): string { + return guarantee.kind; +} + +function indeterminacyEvidence(evidence: NumericalIndeterminacyV1): number | string { + return evidence.reason === "interval-overlap" + ? evidence.bounds.upper - evidence.bounds.lower + : evidence.bounds.kind; +} + +function determinateGlowEvidence(role: GlowDeterminateRole): string { + if (role.decisionProfile === "stable-v1") { + const status: "exact-noop-unreachable" = role.targetStatus; + const selection: null = role.selectionDiagnosticProfile; + const degraded: true = role.degraded; + void selection; + void degraded; + return status; + } + if (role.targetStatus === "legacy-reached") { + const degraded: false = role.degraded; + void degraded; + return role.targetStatus; + } + const status: "legacy-unreachable" = role.targetStatus; + const degraded: true = role.degraded; + void degraded; + return status; +} + +function materialEvidence(role: MaterialRole): number | boolean { + if (role.alphaStatus === "degraded") { + const alpha: 1 = role.alpha; + const guaranteed: false = role.guaranteed; + void alpha; + return guaranteed; + } + const guaranteed: true = role.guaranteed; + if (role.alphaGuarantee.kind === "bisection-bracket-characterized-v1") { + return role.alphaGuarantee.upperAlpha; + } + return guaranteed; +} + +declare const glowBase: GlowDeterminateRoleBase; +// @ts-expect-error stable profile не может нести legacy status. +const impossibleGlow: GlowDeterminateRole = { + ...glowBase, + decisionProfile: "stable-v1", + decisionGuarantee: { kind: "bit-exact" }, + selectionDiagnosticProfile: null, + targetStatus: "legacy-reached", + degraded: false, +}; + +declare const materialBase: MaterialRoleBase; +// @ts-expect-error compatibility boolean выводится из material status/guarantee. +const impossibleMaterial: MaterialRole = { + ...materialBase, + alpha: 1, + alphaGuarantee: { + kind: "opaque-endpoint-characterized-v1", + numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", + }, + alphaStatus: "degraded", + guaranteed: true, +}; + +void [ + initialise, + fingerprint, + resolved, + config, + alphaContract, + glowContract, + decisionEvidence, + indeterminacyEvidence, + determinateGlowEvidence, + materialEvidence, + impossibleGlow, + impossibleMaterial, +]; +`; +} + +async function verifyCleanConsumer( + tarballPath, + packageJson, + packageLock, + expectedBuildMetadata, +) { + const consumer = await mkdtemp(join(tmpdir(), "labcolors-release-consumer-")); + try { + await writeFile( + join(consumer, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + + const typescriptVersion = lockedTypescriptVersion(packageLock); + const localTypescript = await readJson( + resolve(PACKAGE_DIR, "node_modules/typescript/package.json"), + ); + if (localTypescript.version !== typescriptVersion) { + fail( + `installed TypeScript ${localTypescript.version} differs from lockfile ${typescriptVersion}`, + ); + } + const typescriptCompiler = resolve(PACKAGE_DIR, "node_modules/typescript/bin/tsc"); + + npm( + [ + "install", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--save=false", + tarballPath, + ], + consumer, + ); + + const installed = resolve(consumer, "node_modules", ...packageJson.name.split("/")); + const installedPackage = await readJson(resolve(installed, "package.json")); + if (installedPackage.name !== packageJson.name || installedPackage.version !== packageJson.version) { + fail( + `clean install resolved ${installedPackage.name}@${installedPackage.version}, ` + + `expected ${packageJson.name}@${packageJson.version}`, + ); + } + + const installedWasm = await readFile(resolve(installed, "pkg/labcolors_bg.wasm")); + if (sha256(installedWasm) !== expectedBuildMetadata.wasm.sha256) { + fail("clean-installed WASM bytes differ from the packed release input"); + } + const installedBuildMetadata = await readJson(resolve(installed, "build-metadata.json")); + if (!isDeepStrictEqual(installedBuildMetadata, expectedBuildMetadata)) { + fail("clean-installed build metadata differs from the verified release inputs"); + } + + const runtimePath = resolve(consumer, "smoke.mjs"); + const typesPath = resolve(consumer, "smoke.ts"); + await writeFile(runtimePath, runtimeSmokeSource()); + await writeFile(typesPath, typeSmokeSource()); + + command(process.execPath, [runtimePath], consumer); + command( + process.execPath, + [ + typescriptCompiler, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "ES2022", + "--lib", + "ES2022,DOM,ESNext.Disposable", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + typesPath, + ], + consumer, + ); + } finally { + await rm(consumer, { recursive: true, force: true }); + } +} + +// Execute the same packed-package runtime smoke under the caller's Node binary. +// CI uses this to prove the public consumer floor independently from the pinned +// release packer. +export async function smokePackedRuntime(tarballPath) { + const tarball = resolve(tarballPath); + const consumer = await mkdtemp(join(tmpdir(), "labcolors-runtime-smoke-")); + try { + await writeFile( + join(consumer, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + npm( + [ + "install", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--save=false", + tarball, + ], + consumer, + ); + const runtimePath = resolve(consumer, "smoke.mjs"); + await writeFile(runtimePath, runtimeSmokeSource()); + command(process.execPath, [runtimePath], consumer); + } finally { + await rm(consumer, { recursive: true, force: true }); + } +} + +export async function verifyPackageRelease() { + const { sourceSha: source } = await prepareNpmPackage(); + + const [packageJson, packageLock, cargoSource, conformance] = await Promise.all([ + readJson(PACKAGE_JSON), + readJson(PACKAGE_LOCK), + readFile(ROOT_CARGO, "utf8"), + readJson(CONFORMANCE_MANIFEST), + ]); + + const coreVersion = workspaceVersion(cargoSource); + const npmVersion = lockedNpmVersion(packageJson); + const lockRoot = packageLock.packages?.[""]; + if (packageJson.name !== "@labpics/colors") fail(`unexpected npm package name: ${packageJson.name}`); + const consumerNodeFloor = packageJson.engines?.node?.match(/^>=(\d+\.\d+\.\d+)$/u)?.[1]; + if (!consumerNodeFloor) { + fail("package.json engines.node must declare one exact >=X.Y.Z consumer floor"); + } + if (lockRoot?.name !== packageJson.name || lockRoot?.version !== packageJson.version) { + fail("package.json and package-lock.json root identity differ"); + } + if (conformance.coreVersion !== coreVersion) { + fail( + `conformance coreVersion ${conformance.coreVersion} differs from Cargo version ${coreVersion}`, + ); + } + if (!/^\d+\.\d+\.\d+$/u.test(conformance.packVersion ?? "")) { + fail(`invalid conformance packVersion: ${conformance.packVersion}`); + } + const conformanceEvidence = await validateConformance(conformance); + + const wasmBytes = await readFile(WASM_PATH); + if (wasmBytes.length < 8 || !wasmBytes.subarray(0, 4).equals(Buffer.from([0, 97, 115, 109]))) { + fail("pkg/labcolors_bg.wasm is absent or has no WebAssembly magic header"); + } + const wasm = await hashedArtifact(WASM_PATH, "pkg/labcolors_bg.wasm"); + const buildMetadataValue = await readJson(BUILD_METADATA); + validateBuildMetadata(buildMetadataValue, { + packageJson, + source, + coreVersion, + conformanceEvidence, + wasm, + }); + const buildMetadata = await hashedArtifact(BUILD_METADATA, "build-metadata.json"); + + await rm(RELEASE_DIR, { recursive: true, force: true }); + await mkdir(RELEASE_DIR, { recursive: true }); + + const canonicalPack = await packInto(RELEASE_DIR, packageJson); + const reproductionDir = await mkdtemp(join(tmpdir(), "labcolors-release-reproduction-")); + try { + const reproducedPack = await packInto(reproductionDir, packageJson); + if (canonicalPack.tarballName !== reproducedPack.tarballName) { + fail( + `independent npm pack passes produced different filenames: ` + + `${canonicalPack.tarballName} != ${reproducedPack.tarballName}`, + ); + } + const [canonicalBytes, reproducedBytes] = await Promise.all([ + readFile(canonicalPack.path), + readFile(reproducedPack.path), + ]); + if (!canonicalBytes.equals(reproducedBytes)) { + fail("independent npm pack passes produced different tarball bytes"); + } + } finally { + await rm(reproductionDir, { recursive: true, force: true }); + } + + const tarball = await hashedArtifact( + canonicalPack.path, + `.release/${canonicalPack.tarballName}`, + ); + await verifyCleanConsumer( + canonicalPack.path, + packageJson, + packageLock, + buildMetadataValue, + ); + + const manifest = { + schemaVersion: 1, + npm: packageJson.version, + core: coreVersion, + wire: { + identity: `resolved-theme@${packageJson.version}`, + embeddedInPayload: false, + trackingIssue: 258, + }, + conformance: conformanceEvidence, + sourceSha: source, + reproducibility: { + method: "two-independent-npm-pack-passes", + passes: 2, + byteIdentical: true, + }, + requirements: { + consumerRuntime: { + node: packageJson.engines.node, + verifiedFloor: consumerNodeFloor, + canonicalGate: "Node 22 consumer floor", + }, + buildToolchain: { + node: process.versions.node, + npm: npmVersion, + }, + typescript: { + compiler: lockedTypescriptVersion(packageLock), + target: "ES2022", + libraries: ["ES2022", "DOM", "ESNext.Disposable"], + skipLibCheck: false, + }, + }, + supported: [ + "exact-alpha-srgb8-v1", + "exact-screen-composite-srgb8-v1", + "typed-glow-indeterminate-v1", + ], + numericalSites: conformance.numericalSites, + unsupported: [ + "embedded-wire-schema-version", + "stable-cam16-glow-target-or-maximum-selection", + "renderer-or-output-pipeline-equivalence", + "spatial-glow-field", + "display-p3", + ], + artifacts: { tarball, wasm, buildMetadata }, + }; + await writeFile(RELEASE_MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`); + + return { manifest: RELEASE_MANIFEST, tarball: canonicalPack.path }; +} + +async function writeGithubOutputs({ manifest, tarball }) { + const output = process.env.GITHUB_OUTPUT?.trim(); + if (!output) return; + for (const [key, value] of [["manifest", manifest], ["tarball", tarball]]) { + if (value.includes("\n") || value.includes("\r")) { + fail(`unsafe newline in ${key} output path`); + } + await appendFile(output, `${key}=${value}\n`, "utf8"); + } +} + +const invokedDirectly = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + const runtimeSmokeIndex = process.argv.indexOf("--runtime-smoke"); + const action = runtimeSmokeIndex >= 0 + ? (() => { + const tarball = process.argv[runtimeSmokeIndex + 1]; + if (!tarball) fail("--runtime-smoke requires a tarball path"); + return smokePackedRuntime(tarball).then(() => ({ runtimeSmoke: tarball })); + })() + : verifyPackageRelease(); + action + .then(async ({ manifest, tarball }) => { + if (runtimeSmokeIndex >= 0) { + console.log(`runtime smoke passed: ${resolve(process.argv[runtimeSmokeIndex + 1])}`); + return; + } + await writeGithubOutputs({ manifest, tarball }); + console.log(`verified ${relative(REPO_ROOT, tarball).split(sep).join("/")}`); + console.log(`wrote ${relative(REPO_ROOT, manifest).split(sep).join("/")}`); + }) + .catch((error) => { + console.error(error instanceof Error ? error.stack : String(error)); + process.exitCode = 1; + }); +}