diff --git a/README.md b/README.md index 27d22760..45d640a2 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ ThemeConfig клиента сертификат; Glow требует явный decision profile и может завершиться типизированным `Indeterminate` без CSS fallback. - **Непрерывные семейства.** `ColorCurve` и реализации `NeutralCurve`/`AccentCurve` доступны как низкоуровневые вычислительные примитивы. -- **Браузерное применение.** `applyTheme`, `watchTheme`, `adaptTheme` и `effectiveBackground` связывают результат WASM с локальной областью DOM. +- **Браузерное применение.** Публичные `applyTheme`, `watchTheme` и `adaptTheme` + связывают результат WASM с локальной областью DOM; обход подложки остаётся + внутренней частью runtime и не является отдельным API. ## Что не следует приписывать текущей реализации diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index 023a9d23..680c3dae 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -126,8 +126,8 @@ pub fn composite_over_encoded( /// 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-швах +/// `bg + alpha*(tint-bg)` — официальный JS-runtime вызывает этот Core-профиль, +/// а не воспроизводит формулу отдельно. Expanded-форма запрещена: на ULP-швах /// она способна дать последовательность PASS→FAIL→PASS при росте alpha. /// /// # Errors diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index e4c5337d..b8bb7c1a 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -20,6 +20,7 @@ use std::collections::BTreeSet; use crate::Srgb8; +pub(crate) use crate::composition::CompositionProfileV1; /// Непрозрачный handle цветового входа. Число — только идентичность. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -84,15 +85,6 @@ impl OccurrenceId { } } -/// Версионированная identity математической операции композиции. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CompositionProfileV1 { - /// Straight-alpha source-over в encoded-sRGB8: по одному округлению - /// финального канала. Это exact-профиль Lab Colors, не обещание о - /// произвольном браузерном или HDR pipeline. - EncodedSrgb8SourceOverV1, -} - /// Структурная identity статической физической программы. Она описывает /// topology/opcode/profile, а не числовые handles декларации или client ID. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -895,9 +887,10 @@ impl PointOpacityOverSurfaceV1 { opacity: f64, backdrop: [u8; 3], ) -> Result { - crate::composition::validate_alpha(opacity) - .map_err(|message| PointOpacityError { message })?; - let opacity = if opacity == 0.0 { 0.0 } else { opacity }; + let opacity = + crate::composition::AdmittedOpacityV1::new(opacity).map_err(|_| PointOpacityError { + message: format!("alpha вне конечного [0,1]: {opacity}"), + })?; Ok(Self::evaluate_value(source, opacity, backdrop)) } @@ -908,33 +901,35 @@ impl PointOpacityOverSurfaceV1 { opacity: crate::composition::AdmittedOpacityV1, backdrop: [u8; 3], ) -> ResolvedOccurrence { - Self::evaluate_value(source, opacity.value(), backdrop) + Self::evaluate_value(source, opacity, backdrop) } - fn evaluate_value(source: [u8; 3], opacity: f64, backdrop: [u8; 3]) -> ResolvedOccurrence { + fn evaluate_value( + source: [u8; 3], + opacity: crate::composition::AdmittedOpacityV1, + backdrop: [u8; 3], + ) -> ResolvedOccurrence { let mut paints = [None; 2]; let mut surfaces = [None; 2]; let mut occurrences = [None; 1]; POINT_OPACITY_OVER_SURFACE_V1.execute_into( - |id| match id { - POINT_SOURCE => Srgb8::new(source), - _ => unreachable!("sealed point program has one ColorInput port"), + |id| { + debug_assert_eq!(id, POINT_SOURCE); + Srgb8::new(source) }, - |id| match id { - POINT_CONTEXT => Srgb8::new(backdrop), - _ => unreachable!("sealed point program has one SurfaceInput port"), + |id| { + debug_assert_eq!(id, POINT_CONTEXT); + Srgb8::new(backdrop) }, - |id| match id { - POINT_OPACITY => opacity, - _ => unreachable!("sealed point program has one OpacityInput port"), + |id| { + debug_assert_eq!(id, POINT_OPACITY); + opacity }, &mut paints, &mut surfaces, &mut occurrences, ); - occurrences[0].unwrap_or_else(|| { - unreachable!("compiler-verified point program materializes its Occurrence") - }) + occurrences[0].unwrap_or_else(|| unreachable!()) } } @@ -1006,13 +1001,13 @@ impl AppearanceBindings { } } -/// Материализованный point Paint. Значение alpha хранится битами, чтобы -/// equality/certificate не теряли binary64 representation. +/// Материализованный point Paint. Тип alpha делает повторную admission перед +/// каждым occurrence невозможной; его биты без потерь переходят в certificate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ResolvedPaint { id: PaintId, rgb: Srgb8, - opacity_bits: u64, + opacity: crate::composition::AdmittedOpacityV1, } impl ResolvedPaint { @@ -1023,7 +1018,7 @@ impl ResolvedPaint { #[cfg(test)] pub(crate) fn opacity_bits(&self) -> u64 { - self.opacity_bits + self.opacity.bits() } } @@ -1033,23 +1028,16 @@ impl ResolvedPaint { pub(crate) struct SourceOverCertificateV1 { profile: CompositionProfileV1, subject_rgb: [u8; 3], - subject_opacity_bits: u64, + subject_opacity: crate::composition::AdmittedOpacityV1, backdrop_rgb: [u8; 3], output_rgb: [u8; 3], } impl SourceOverCertificateV1 { #[cfg(test)] - pub(crate) fn replay(&self) -> Result<[u8; 3], String> { - match self.profile { - CompositionProfileV1::EncodedSrgb8SourceOverV1 => { - crate::composition::source_over_srgb8( - self.subject_rgb, - f64::from_bits(self.subject_opacity_bits), - self.backdrop_rgb, - ) - } - } + pub(crate) fn replay(&self) -> [u8; 3] { + self.profile + .composite(self.subject_rgb, self.subject_opacity, self.backdrop_rgb) } #[cfg(test)] @@ -1062,7 +1050,7 @@ impl SourceOverCertificateV1 { } pub(crate) fn subject_opacity_bits(&self) -> u64 { - self.subject_opacity_bits + self.subject_opacity.bits() } pub(crate) fn backdrop_rgb(&self) -> [u8; 3] { @@ -1336,14 +1324,12 @@ impl CompiledAppearanceProgram<'_> { .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); surfaces[index].1 }; - let opacity_value = |id: OpacityInputId| -> f64 { + let opacity_value = |id: OpacityInputId| -> crate::composition::AdmittedOpacityV1 { let index = opacities .binary_search_by_key(&id, |(bound, _)| *bound) .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); - let alpha = opacities[index].1; - // Straight alpha — не signed quantity: ±0 описывают один - // физический state и не должны минтить разные certificate bits. - if alpha == 0.0 { 0.0 } else { alpha } + crate::composition::AdmittedOpacityV1::new(opacities[index].1) + .unwrap_or_else(|_| unreachable!("opacity bindings were admitted before execution")) }; let mut resolved_paints: Vec> = vec![None; self.paints.len()]; @@ -1402,7 +1388,7 @@ impl CompiledAppearanceProgram<'_> { ) where C: Fn(ColorInputId) -> Srgb8, S: Fn(SurfaceInputPortId) -> Srgb8, - O: Fn(OpacityInputId) -> f64, + O: Fn(OpacityInputId) -> crate::composition::AdmittedOpacityV1, { debug_assert_eq!(resolved_paints.len(), self.paints.len()); debug_assert_eq!(resolved_surfaces.len(), self.surfaces.len()); @@ -1413,21 +1399,18 @@ impl CompiledAppearanceProgram<'_> { CompiledPaintSpec::Solid { id, color } => ResolvedPaint { id, rgb: color_value(color), - opacity_bits: 1.0f64.to_bits(), + opacity: crate::composition::AdmittedOpacityV1::OPAQUE, }, CompiledPaintSpec::Opacity { id, source, opacity, } => { - let source = resolved_paints[source] - .unwrap_or_else(|| unreachable!("Paint dependency precedes its consumer")); - let effective_alpha = - f64::from_bits(source.opacity_bits) * opacity_value(opacity); + let source = resolved_paints[source].unwrap_or_else(|| unreachable!()); ResolvedPaint { id, rgb: source.rgb, - opacity_bits: effective_alpha.to_bits(), + opacity: source.opacity.multiply(opacity_value(opacity)), } } }; @@ -1441,9 +1424,7 @@ impl CompiledAppearanceProgram<'_> { CompiledSurfaceSpec::Input { port, .. } => surface_value(port), CompiledSurfaceSpec::FromOccurrence { occurrence, .. } => Srgb8::new( resolved_occurrences[occurrence] - .unwrap_or_else(|| { - unreachable!("occurrence precedes surfaceFrom in render topo") - }) + .unwrap_or_else(|| unreachable!()) .visible(), ), }; @@ -1451,24 +1432,18 @@ impl CompiledAppearanceProgram<'_> { } RenderNode::Occurrence(index) => { let spec = &self.occurrences[index]; - let subject = resolved_paints[spec.subject] - .unwrap_or_else(|| unreachable!("Paint DAG is evaluated first")); - let backdrop = resolved_surfaces[spec.against].unwrap_or_else(|| { - unreachable!("backdrop precedes occurrence in render topo") - }); - let visible = match spec.profile { - CompositionProfileV1::EncodedSrgb8SourceOverV1 => { - crate::composition::source_over_srgb8_validated( - subject.rgb.bytes(), - f64::from_bits(subject.opacity_bits), - backdrop.bytes(), - ) - } - }; + let subject = resolved_paints[spec.subject].unwrap_or_else(|| unreachable!()); + let backdrop = + resolved_surfaces[spec.against].unwrap_or_else(|| unreachable!()); + let visible = spec.profile.composite( + subject.rgb.bytes(), + subject.opacity, + backdrop.bytes(), + ); let certificate = SourceOverCertificateV1 { profile: spec.profile, subject_rgb: subject.rgb.bytes(), - subject_opacity_bits: subject.opacity_bits, + subject_opacity: subject.opacity, backdrop_rgb: backdrop.bytes(), output_rgb: visible, }; diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index d2c4f6c5..6c074699 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -130,7 +130,7 @@ fn canonical_paint_occurrence_surface_chain_evaluates_exactly() { .unwrap() .certificate() .replay(), - Ok([0xFF, 0xF4, 0xE0]) + [0xFF, 0xF4, 0xE0] ); } @@ -348,7 +348,7 @@ fn nested_opacity_materializes_once_by_multiplying_opacity() { .unwrap() .certificate() .replay(), - Ok(rendered.occurrence(FILL_OCCURRENCE).unwrap().visible()) + rendered.occurrence(FILL_OCCURRENCE).unwrap().visible() ); } @@ -415,7 +415,7 @@ fn nested_opacity_preserves_subnormal_and_rounds_underflow_to_positive_zero() { expected_bits ); assert_eq!(occurrence.visible(), [0; 3]); - assert_eq!(occurrence.certificate().replay(), Ok([0; 3])); + assert_eq!(occurrence.certificate().replay(), [0; 3]); } } @@ -556,8 +556,8 @@ fn one_paint_is_surface_agnostic_across_two_occurrences() { assert_eq!(first.subject(), FILL_PAINT); assert_eq!(second.subject(), FILL_PAINT); assert_ne!(first.visible(), second.visible()); - assert_eq!(first.certificate().replay(), Ok(first.visible())); - assert_eq!(second.certificate().replay(), Ok(second.visible())); + assert_eq!(first.certificate().replay(), first.visible()); + assert_eq!(second.certificate().replay(), second.visible()); } #[test] @@ -666,11 +666,11 @@ fn surface_from_reuses_visible_result_without_recompositing() { assert_ne!(second_occurrence.visible(), [255, 128, 128]); assert_eq!( first_occurrence.certificate().replay(), - Ok(first_occurrence.visible()) + first_occurrence.visible() ); assert_eq!( second_occurrence.certificate().replay(), - Ok(second_occurrence.visible()) + second_occurrence.visible() ); } @@ -718,7 +718,7 @@ proptest! { prop_assert_eq!(program_occurrence.backdrop_surface(), CONTEXT_SURFACE); prop_assert_eq!(certificate.backdrop_rgb(), context); prop_assert_eq!(certificate.output_rgb(), rendered.occurrence(FILL_OCCURRENCE).unwrap().visible()); - prop_assert_eq!(certificate.replay(), Ok(certificate.output_rgb())); + prop_assert_eq!(certificate.replay(), certificate.output_rgb()); } #[test] @@ -778,7 +778,7 @@ proptest! { let oracle = crate::alpha::composite_over_srgb8(source, effective, context).unwrap(); prop_assert_eq!(rendered.paint(subject).unwrap().opacity_bits(), effective.to_bits()); prop_assert_eq!(occurrence.visible(), oracle); - prop_assert_eq!(occurrence.certificate().replay(), Ok(oracle)); + prop_assert_eq!(occurrence.certificate().replay(), oracle); } #[test] diff --git a/crates/labcolors-core/src/composition.rs b/crates/labcolors-core/src/composition.rs index 6da7cf14..26b83562 100644 --- a/crates/labcolors-core/src/composition.rs +++ b/crates/labcolors-core/src/composition.rs @@ -12,6 +12,18 @@ pub(crate) enum OpacityAdmissionErrorV1 { OutsideUnitInterval, } +/// Версионированная identity единственной point-операции композиции. +/// +/// Профиль и исполняющий его закон живут вместе: graph executor, alpha-аналог +/// и certificate replay не вправе независимо выбирать арифметику по тому же +/// discriminant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompositionProfileV1 { + /// Straight-alpha source-over в encoded-sRGB8 с одним округлением каждого + /// финального канала occurrence. Это не модель произвольного renderer/HDR. + EncodedSrgb8SourceOverV1, +} + /// Канонический straight alpha внутри конечного `[0,1]`. /// /// Значение хранится битами: `-0.0` понижается в единственный физический `+0.0` @@ -20,6 +32,8 @@ pub(crate) enum OpacityAdmissionErrorV1 { pub(crate) struct AdmittedOpacityV1(u64); impl AdmittedOpacityV1 { + pub(crate) const OPAQUE: Self = Self(1.0f64.to_bits()); + pub(crate) fn new(alpha: f64) -> Result { if !alpha.is_finite() { return Err(OpacityAdmissionErrorV1::NonFinite); @@ -31,7 +45,6 @@ impl AdmittedOpacityV1 { Ok(Self(canonical.to_bits())) } - #[cfg(test)] pub(crate) const fn bits(self) -> u64 { self.0 } @@ -39,11 +52,40 @@ impl AdmittedOpacityV1 { pub(crate) const fn value(self) -> f64 { f64::from_bits(self.0) } + + /// Композиция opacity-конструкторов замкнута в admitted `[0,1]`: два + /// конечных неотрицательных множителя не могут создать новый invalid state. + pub(crate) fn multiply(self, rhs: Self) -> Self { + Self((self.value() * rhs.value()).to_bits()) + } +} + +impl CompositionProfileV1 { + /// Исполняет ровно тот закон, identity которого несёт профиль. + pub(crate) fn composite( + self, + tint: [u8; 3], + alpha: AdmittedOpacityV1, + backdrop: [u8; 3], + ) -> [u8; 3] { + match self { + Self::EncodedSrgb8SourceOverV1 => { + #[cfg(test)] + SOURCE_OVER_EVALUATIONS.with(|count| count.set(count.get() + 1)); + let alpha = alpha.value(); + [ + source_over_channel_srgb8(tint[0], alpha, backdrop[0]), + source_over_channel_srgb8(tint[1], alpha, backdrop[1]), + source_over_channel_srgb8(tint[2], alpha, backdrop[2]), + ] + } + } + } } -/// Порядок binary64-операций совпадает с официальным JS-потребителем на -/// непрозрачной подложке. Expanded-форма запрещена: два округления нарушают -/// монотонность на отдельных ULP-швах. +/// Это declared binary64 operation order официального runtime: JS вызывает +/// Core, отдельной формулы у него нет. Expanded-форма запрещена: два округления +/// нарушают монотонность на отдельных ULP-швах. pub(crate) fn source_over_channel_value(tint: u8, alpha: f64, backdrop: u8) -> f64 { f64::from(backdrop) + alpha * (f64::from(tint) - f64::from(backdrop)) } @@ -52,11 +94,11 @@ pub(crate) fn source_over_channel_srgb8(tint: u8, alpha: f64, backdrop: u8) -> u source_over_channel_value(tint, alpha, backdrop).round() as u8 } +#[cfg(test)] pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { - if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { - return Err(format!("alpha вне конечного [0,1]: {alpha}")); - } - Ok(()) + AdmittedOpacityV1::new(alpha) + .map(|_| ()) + .map_err(|_| format!("alpha вне конечного [0,1]: {alpha}")) } pub(crate) fn source_over_srgb8( @@ -64,19 +106,9 @@ pub(crate) fn source_over_srgb8( alpha: f64, backdrop: [u8; 3], ) -> Result<[u8; 3], String> { - validate_alpha(alpha)?; - Ok(source_over_srgb8_validated(tint, alpha, backdrop)) -} - -pub(crate) fn source_over_srgb8_validated(tint: [u8; 3], alpha: f64, backdrop: [u8; 3]) -> [u8; 3] { - debug_assert!(validate_alpha(alpha).is_ok()); - #[cfg(test)] - SOURCE_OVER_EVALUATIONS.with(|count| count.set(count.get() + 1)); - [ - source_over_channel_srgb8(tint[0], alpha, backdrop[0]), - source_over_channel_srgb8(tint[1], alpha, backdrop[1]), - source_over_channel_srgb8(tint[2], alpha, backdrop[2]), - ] + let alpha = + AdmittedOpacityV1::new(alpha).map_err(|_| format!("alpha вне конечного [0,1]: {alpha}"))?; + Ok(CompositionProfileV1::EncodedSrgb8SourceOverV1.composite(tint, alpha, backdrop)) } #[cfg(test)] @@ -93,3 +125,22 @@ pub(crate) fn reset_source_over_evaluation_count() { pub(crate) fn source_over_evaluation_count() -> usize { SOURCE_OVER_EVALUATIONS.with(std::cell::Cell::get) } + +#[cfg(test)] +mod tests { + use super::AdmittedOpacityV1; + + #[test] + fn admitted_opacity_multiplication_is_closed_at_boundaries_and_underflow() { + let zero = AdmittedOpacityV1::new(0.0).unwrap(); + let one = AdmittedOpacityV1::new(1.0).unwrap(); + assert_eq!(zero.multiply(zero).value(), 0.0); + assert_eq!(one.multiply(one).value(), 1.0); + + let smallest_subnormal = AdmittedOpacityV1::new(f64::from_bits(1)).unwrap(); + let half = AdmittedOpacityV1::new(0.5).unwrap(); + let underflow = smallest_subnormal.multiply(half); + assert_eq!(underflow.value(), 0.0); + assert_eq!(underflow.bits(), 0.0_f64.to_bits()); + } +} diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 286bc62e..bc9df369 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1801,8 +1801,9 @@ impl GlowIndeterminateResolved { /// ([`pole`](Self::pole)) держит [`floor`](Self::floor) по всему коридору /// `[чёрный, белый]` ([`crate::material`]). [`worst_contrast`](Self::worst_contrast) /// и [`alpha_status`](Self::alpha_status) пересчитываемы потребителем из эмитированных -/// `01`/`02`: ядро и официальный `packages/colors/effective-bg.js::compositeOver` -/// используют один byte-scale affine order `B + α·(T−B)`. +/// `01`/`02` только по зафиксированному continuous interval profile +/// [`crate::material`]: byte-scale affine order `B + α·(T−B)` исполняется без +/// промежуточного округления в sRGB8, затем расширяется conservative envelope. #[derive(Debug, Clone, PartialEq)] pub struct MaterialResolved { tone_hex: String, diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 0e484649..2e7ced83 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -585,6 +585,41 @@ pub fn evaluate_wcag22( Ok(parsed.unchecked_into()) } +const INVALID_RGB24: u32 = u32::MAX; + +fn unpack_rgb24(value: u32) -> [u8; 3] { + [ + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ] +} + +fn pack_rgb24([red, green, blue]: [u8; 3]) -> u32 { + (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) +} + +/// Package-private scalar bridge for the canonical point compositor. +/// +/// `0x00RRGGBB` keeps the hot successful boundary allocation-free. The +/// unreachable RGB24 value `0xFFFFFFFF` is the opacity-rejection sentinel; +/// `effective-bg.js` turns it into a loud internal failure instead of a colour. +/// RGB24 words are package-constructed, not a second public parser boundary. +/// The package root deliberately hides both this seam and raw init exports. +#[wasm_bindgen(js_name = __over)] +pub fn point_source_over_encoded_srgb8_v1( + source_rgb24: u32, + opacity: f64, + backdrop_rgb24: u32, +) -> u32 { + let source = unpack_rgb24(source_rgb24); + let backdrop = unpack_rgb24(backdrop_rgb24); + labcolors_core::alpha::composite_over_srgb8(source, opacity, backdrop) + .ok() + .map(pack_rgb24) + .unwrap_or(INVALID_RGB24) +} + /// Контрастный движок над дизайн-системой клиента. Создайте его через /// [`LabColors::new`], загрузите конфиг методом /// [`loadConfig`](LabColors::load_config), затем многократно вызывайте @@ -911,4 +946,33 @@ mod native_contract_tests { assert!(matches!(error, BindingError::Internal { .. })); assert_eq!(error.code(), "internal_error"); } + + #[test] + fn packed_point_boundary_matches_the_independent_rational_oracle() { + for source in u8::MIN..=u8::MAX { + for backdrop in u8::MIN..=u8::MAX { + let source_rgb24 = u32::from(source) << 16; + let backdrop_rgb24 = u32::from(backdrop) << 16; + let actual = + point_source_over_encoded_srgb8_v1(source_rgb24, 0.122, backdrop_rgb24); + let numerator = 122_u32 * u32::from(source) + 878_u32 * u32::from(backdrop); + let expected = ((numerator + 500) / 1_000) << 16; + assert_eq!(actual, expected, "source={source}, backdrop={backdrop}"); + } + } + } + + #[test] + fn packed_point_boundary_rejects_every_invalid_opacity() { + for opacity in [f64::NAN, f64::NEG_INFINITY, -0.1, 1.1, f64::INFINITY] { + assert_eq!( + point_source_over_encoded_srgb8_v1(0, opacity, 0), + INVALID_RGB24 + ); + } + assert_eq!( + point_source_over_encoded_srgb8_v1(0, -0.0, 0x12_34_56), + 0x12_34_56 + ); + } } diff --git a/packages/colors/README.md b/packages/colors/README.md index edc9efa5..39868026 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -58,10 +58,17 @@ const result = engine.resolveTheme("#FFFFFF", "light"); applyTheme(document.documentElement, result); // записать все --lab-* в элемент ``` +Параллельные `init()` разделяют одну загрузку; повторный вызов после успеха — +no-op. `initSync()` нельзя смешивать с ещё не завершившимся `init()`: фасад +отклонит гонку до создания второго WASM-инстанса. `init()` разрешается в +`void`, `initSync()` возвращает `void`; raw WebAssembly exports они не раскрывают. + ### Реактивное отслеживание `watchTheme` синхронизирует переменные с явно переданным фоном или опорной -оценкой поддерживаемой цепочки `background-color`. +оценкой поддерживаемой цепочки `background-color`. Каждый поддерживаемый слой +этой цепочки проходит тот же exact encoded-sRGB8 point-композитор Core, что и +occurrence-граф; отдельной JS-формулы и публичного compositor API нет. ```ts import init, { LabColors, watchTheme } from "@labpics/colors"; @@ -381,7 +388,7 @@ interface WatchThemeOptions { theme: ThemeName; background?: string | (() => string); // явный фон (если автоматический невозможен) target?: HTMLElement; // куда писать переменные (по умолчанию: element) - fallback?: string; // фон при полностью прозрачной цепочке (по умолчанию "#FFFFFF") + fallback?: string; // непрозрачная поддерживаемая база (по умолчанию "#FFFFFF") observe?: boolean; // авто-обновление при style/class в поддереве (по умолчанию true) onError?: (error: unknown) => void; // ошибки автоматического observer-refresh root?: Node; // корень MutationObserver (по умолчанию: documentElement) @@ -428,7 +435,7 @@ interface AdaptThemeOptions { background?: string | string[] | (() => string | string[]); // один hex или несколько образцов фона (наихудший учитывается) target?: HTMLElement; // куда писать переменные (по умолчанию: element) - fallback?: string; // фон при прозрачной цепочке (по умолчанию "#FFFFFF") + fallback?: string; // непрозрачная поддерживаемая база (по умолчанию "#FFFFFF") dropFraction?: number; // запас контраста до пересчёта (по умолчанию 0.2) sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120) dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250) diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index 26184ad3..f6b2a47c 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -30,7 +30,7 @@ export interface AdaptThemeOptions { background?: string | string[] | (() => string | string[]); /** Element to write the `--lab-*` variables onto. Defaults to the watched element. */ target?: HTMLElement; - /** Base colour when the ancestor chain is fully translucent. Default `"#FFFFFF"`. */ + /** Непрозрачная поддерживаемая база полностью прозрачной цепочки. По умолчанию `"#FFFFFF"`. */ fallback?: string; /** Fraction of a role's contrast surplus that may be lost before a re-solve. Default `0.2`. */ dropFraction?: number; diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index f7518a05..1535ecff 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -75,7 +75,7 @@ function segHex(seg, t) { * sample must be a non-empty string; invalid explicit evidence is rejected * without coercion or fallback. * @param {*} [options.target=element] element to write vars onto - * @param {string} [options.fallback="#FFFFFF"] + * @param {string} [options.fallback="#FFFFFF"] Opaque supported base for a fully-translucent chain. * @param {number} [options.dropFraction=0.2] surplus fraction lost before re-solve * @param {number} [options.sustainMs=120] breach must persist this long * @param {number} [options.dwellMs=250] minimum between re-solves diff --git a/packages/colors/bench/hotpath.bench.mjs b/packages/colors/bench/hotpath.bench.mjs index 4900211b..bff363d4 100644 --- a/packages/colors/bench/hotpath.bench.mjs +++ b/packages/colors/bench/hotpath.bench.mjs @@ -2,9 +2,9 @@ // // Measures the per-frame cost of `adaptTheme` (the rAF-driven controller) and // its supporting primitives (`oklabLerp`, `parseCssColor`, -// `effectiveBackground`) on a manual clock with a stub engine, so numbers are -// reproducible and independent of WASM/solver cost — this isolates exactly the -// JS overhead a weak device pays every frame. +// `effectiveBackground`) on a manual clock. Controller scenarios use a stub +// engine and isolate JS overhead; the effective-background microbenchmark also +// includes the allocation-free JS↔WASM point-compositor boundary it executes. // // Every scenario is fully deterministic: same schedule, same colours, same // breach timing. Besides timing, each scenario reports a BEHAVIOUR FINGERPRINT @@ -16,10 +16,16 @@ // // Run: node bench/hotpath.bench.mjs +import { readFileSync } from "node:fs"; import { performance } from "node:perf_hooks"; +import { initSync } from "../index.js"; import { adaptTheme } from "../adapt-theme.js"; import { oklabLerp, parseCssColor, effectiveBackground } from "../effective-bg.js"; +initSync({ + module: readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url)), +}); + const FRAME_MS = 1000 / 60; const WARMUP_FRAMES = 300; const MEASURE_FRAMES = 3000; @@ -213,17 +219,14 @@ const PARSE_FORMS = [ function fakeChain(depth) { // depth translucent rgba layers over an opaque root — the worst honest case // for the ancestor walk. - const nodes = []; - let parent = null; - for (let i = 0; i < depth; i++) { - const css = - i === depth - 1 ? "rgb(240, 240, 240)" : `rgba(${20 + i * 7}, ${30 + i * 5}, ${40 + i * 3}, 0.35)`; - const node = { css, parent }; - nodes.unshift(node); - parent = null; + let leaf = { css: "rgb(240, 240, 240)", parent: null }; + for (let i = depth - 1; i >= 0; i--) { + leaf = { + css: `rgba(${20 + i * 7}, ${30 + i * 5}, ${40 + i * 3}, 0.35)`, + parent: leaf, + }; } - for (let i = 0; i < nodes.length - 1; i++) nodes[i].parent = nodes[i + 1]; - return nodes[0]; + return leaf; } // ── run ───────────────────────────────────────────────────────────────────── @@ -250,6 +253,17 @@ for (const s of scenarios) { console.log(""); console.log("micro ns/op"); const chain = fakeChain(8); +let probeReads = 0; +const probe = effectiveBackground(chain, { + getStyle: (el) => { + probeReads++; + return { getPropertyValue: () => el.css }; + }, + parentOf: (el) => el.parent, +}); +if (probeReads !== 9 || probe === "#F0F0F0") { + throw new Error("effectiveBackground benchmark did not traverse its translucent stack"); +} const micros = [ micro("oklabLerp hex→hex", 2e5, (i) => oklabLerp("#1A2B3C", "#F0E1D2", (i % 100) / 100)), micro("oklabLerp oklch→hex", 1e5, (i) => oklabLerp("oklch(62.8% 0.2577 29.2)", "#F0E1D2", (i % 100) / 100)), diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index c9c8ee45..c8a47a55 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29697431855", + "source": "github-actions-run-29701262743", "platform": "linux-x64", - "rawBytes": 387135 + "rawBytes": 387058 }, "policy": { - "maxRawBytes": 387135, - "basis": "v1a-revision-bound-recheck-exact-head", + "maxRawBytes": 387058, + "basis": "c8b-canonical-point-compositor-exact-head", "gzip": "diagnostic-only" } } diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index 1970e490..d54f3d70 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -1,4 +1,5 @@ -// Effective background resolution — zero dependencies. +// Package-internal effective background traversal. Point composition belongs +// to the WASM Core; this shell only parses CSS, walks host state and packs RGB. // // `labcolors` resolves roles against a *solid* background. A real UI surface is // often translucent (a panel at `rgba(…, .8)` over its parents) or has no @@ -29,6 +30,8 @@ // dropped layer for compatibility. That is not safe evidence: pass the // background explicitly when any unsupported layer affects the decision. +import { __over } from "./pkg/labcolors.js"; + /** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */ /** @@ -62,6 +65,7 @@ export function parseCssColor(css) { if (s[0] === "#") { const h = s.slice(1); + if (!/^[0-9a-f]+$/u.test(h)) return null; if (h.length === 3 || h.length === 4) { const r = parseInt(h[0] + h[0], 16); const g = parseInt(h[1] + h[1], 16); @@ -81,15 +85,38 @@ export function parseCssColor(css) { const m = s.match(/^rgba?\(([^)]+)\)$/); if (!m) return null; - // Split on commas or whitespace and an optional "/" alpha separator. - const parts = m[1].split(/[,\s/]+/).filter((p) => p.length > 0); - if (parts.length < 3) return null; - const chan = (p) => (p.endsWith("%") ? (parseFloat(p) / 100) * 255 : parseFloat(p)); - const r = chan(parts[0]); - const g = chan(parts[1]); - const b = chan(parts[2]); - const a = parts.length >= 4 ? (parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3])) : 1; - if ([r, g, b, a].some((v) => Number.isNaN(v))) return null; + const body = m[1].trim(); + let channels; + let alphaToken = null; + if (body.includes(",")) { + if (body.includes("/")) return null; + const parts = body.split(",").map((part) => part.trim()); + if (parts.some((part) => part.length === 0) || (parts.length !== 3 && parts.length !== 4)) { + return null; + } + channels = parts.slice(0, 3); + if (parts.length === 4) alphaToken = parts[3]; + } else { + const slash = body.split("/").map((part) => part.trim()); + if (slash.length > 2 || slash.some((part) => part.length === 0)) return null; + channels = slash[0].split(/\s+/u); + if (channels.length !== 3) return null; + if (slash.length === 2) { + const alphaParts = slash[1].split(/\s+/u); + if (alphaParts.length !== 1) return null; + alphaToken = alphaParts[0]; + } + } + const chan = (p) => { + const pct = p.endsWith("%"); + const value = cssNumber(pct ? p.slice(0, -1) : p); + return value === null ? null : pct ? (value / 100) * 255 : value; + }; + const r = chan(channels[0]); + const g = chan(channels[1]); + const b = chan(channels[2]); + const a = alphaToken === null ? 1 : oklchAlpha(alphaToken); + if ([r, g, b, a].some((value) => value === null)) return null; return [clamp255(r), clamp255(g), clamp255(b), Math.min(1, Math.max(0, a))]; } @@ -126,13 +153,22 @@ function parseOklch(inner) { const hRad = (H * Math.PI) / 180; const lin = oklabToLinearRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); - const byte = (i) => Math.round(clamp255(linearToSrgb(lin[i]) * 255)); - return [byte(0), byte(1), byte(2), a]; + if (lin.some((channel) => !Number.isFinite(channel))) return null; + const encoded = lin.map((channel) => linearToSrgb(channel) * 255); + if (encoded.some((channel) => !Number.isFinite(channel))) return null; + return [ + Math.round(clamp255(encoded[0])), + Math.round(clamp255(encoded[1])), + Math.round(clamp255(encoded[2])), + a, + ]; } /** Strict CSS `` (no trailing junk, unlike `parseFloat`), else `null`. */ function cssNumber(tok) { - return /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(tok) ? parseFloat(tok) : null; + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(tok)) return null; + const value = Number(tok); + return Number.isFinite(value) ? value : null; } /** L: a percentage → `/100` into `0..1`; a bare number is already `0..1`; `none` @@ -172,29 +208,6 @@ function oklchAlpha(tok) { return Math.min(1, Math.max(0, pct ? n / 100 : n)); } -/** - * Source-over composite of `top` onto `bottom` (Porter-Duff "over"). - * - * @param {Rgba} top - * @param {Rgba} bottom - * @returns {Rgba} - */ -export function compositeOver(top, bottom) { - const at = top[3]; - const ab = bottom[3]; - // 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) => { - const bottomPremultiplied = bottom[i] * ab; - return (bottomPremultiplied + at * (top[i] - bottomPremultiplied)) / a; - }; - return [c(0), c(1), c(2), a]; -} - /** * `[r, g, b]` (0..255) → `#RRGGBB`, channels rounded and clamped. * @@ -361,22 +374,37 @@ export function lerpPairHex(pair, t) { return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]); } +const INVALID_RGB24 = 0xFFFFFFFF; + +/** Квантует допустимый CSS parser-result в identity encoded-sRGB8. Это только + * boundary packing; source-over и его округление принадлежат Core. */ +function packRgb24(rgb) { + const byte = (value) => Math.round(clamp255(Number.isFinite(value) ? value : 0)); + return ((byte(rgb[0]) << 16) | (byte(rgb[1]) << 8) | byte(rgb[2])) >>> 0; +} + +function hexFromRgb24(rgb24) { + return `#${rgb24.toString(16).padStart(6, "0").toUpperCase()}`; +} + /** - * Compose an ordered stack of colour layers (front-to-back) over an opaque base - * into a single opaque `#RRGGBB`. Pure — no DOM; package-internal until the - * occurrence observer replaces this compatibility estimate. + * Канонический point-stack: каждый слой материализуется отдельным occurrence, + * поэтому Core округляет каждый edge, а не только итог всей цепочки. * - * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer - * @param {Rgba} opaqueBase must have alpha 1 + * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer + * @param {Rgba} opaqueBase must have alpha 1 * @returns {string} */ -export function compositeStackToHex(layersFrontToBack, opaqueBase) { - let result = opaqueBase; - // Apply from the back (closest to base) forward, so index 0 lands on top. +function compositePointStack(layersFrontToBack, opaqueBase) { + let result = packRgb24(opaqueBase); for (let i = layersFrontToBack.length - 1; i >= 0; i--) { - result = compositeOver(layersFrontToBack[i], result); + const layer = layersFrontToBack[i]; + result = __over(packRgb24(layer), layer[3], result); + if (result === INVALID_RGB24) { + throw new RangeError("effectiveBackground: Core rejected an admitted point layer"); + } } - return toHex(result); + return hexFromRgb24(result); } /** @@ -393,7 +421,7 @@ export function compositeStackToHex(layersFrontToBack, opaqueBase) { * * @param {*} element * @param {object} [opts] - * @param {string} [opts.fallback="#FFFFFF"] base when the chain is fully translucent + * @param {string} [opts.fallback="#FFFFFF"] opaque supported base when the chain is fully translucent * @param {(el: *) => { getPropertyValue: (p: string) => string }} [opts.getStyle] * @param {(el: *) => *} [opts.parentOf] * @param {number} [opts.maxDepth=64] guard against detached/cyclic chains @@ -401,6 +429,12 @@ export function compositeStackToHex(layersFrontToBack, opaqueBase) { */ export function effectiveBackground(element, opts = {}) { const fallback = opts.fallback ?? "#FFFFFF"; + const admittedFallback = parseCssColor(fallback); + if (!admittedFallback || admittedFallback[3] !== 1) { + throw new RangeError( + "effectiveBackground: fallback must be an opaque supported colour", + ); + } const getStyle = opts.getStyle ?? ((el) => (typeof getComputedStyle === "function" ? getComputedStyle(el) : { getPropertyValue: () => "" })); const parentOf = opts.parentOf ?? ((el) => el.parentElement); @@ -415,7 +449,7 @@ export function effectiveBackground(element, opts = {}) { const layers = []; let el = element; let depth = 0; - let base = parseCssColor(fallback) ?? [255, 255, 255, 1]; + let base = admittedFallback; while (el && depth < maxDepth) { const style = getStyle(el); @@ -441,5 +475,5 @@ export function effectiveBackground(element, opts = {}) { depth++; } - return compositeStackToHex(layers, base); + return compositePointStack(layers, base); } diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index 75ccd697..db22b1bf 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -10,13 +10,26 @@ import type { Wcag22AssessmentV1 } from "./pkg/labcolors.js"; import type { Wcag22CriterionV1 } from "./wcag22.js"; export { - default, - default as init, - initSync, LabColors, numericalCapabilityManifest, } from "./pkg/labcolors.js"; +/** Inputs accepted by the asynchronous WASM loader. */ +type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +/** Inputs accepted by the synchronous WASM loader. */ +type SyncInitInput = BufferSource | WebAssembly.Module; + +/** Initialise the runtime without exposing its raw WebAssembly exports. */ +export declare function init( + input?: { module_or_path: InitInput | Promise } | InitInput | Promise, +): Promise; + +/** Initialise the runtime synchronously without exposing raw WebAssembly exports. */ +export declare function initSync(input: { module: SyncInitInput } | SyncInitInput): void; + +export default init; + /** Exact WCAG 2.2 assessment for one canonical final-sRGB8 occurrence. */ export declare function evaluateWcag22( foreground: string, diff --git a/packages/colors/index.js b/packages/colors/index.js index f0e7ccf5..fbaffb5d 100644 --- a/packages/colors/index.js +++ b/packages/colors/index.js @@ -1,19 +1,92 @@ // Public entry for @labpics/colors. // -// Re-exports the wasm-bindgen surface (the default `init` loader, `initSync`, -// and the `LabColors` engine class) plus the vanilla DOM runtime helpers: +// Curates the wasm-bindgen surface plus the vanilla DOM runtime helpers: // `applyTheme` (one-shot apply), `watchTheme` (reactive sync), and // `adaptTheme` (sample-driven adaptation). +import initWasm, { initSync as initWasmSync } from "./pkg/labcolors.js"; + +let initState = "idle"; +let initFlight; + export { - default, - default as init, - initSync, LabColors, evaluateWcag22, numericalCapabilityManifest, } from "./pkg/labcolors.js"; +// wasm-bindgen returns every raw export from its loaders. The public facade +// deliberately erases that value: initialization is an effect, not a second +// uncurated ABI beside the typed package surface. +export function init(input) { + if (initState === "ready") return Promise.resolve(); + if (initState === "async") return initFlight; + if (initState === "starting") { + throw new Error("Lab Colors: initialization input admission is in progress"); + } + if (initState === "sync") { + throw new Error("Lab Colors: synchronous initialization is in progress"); + } + + let resolveFlight; + let rejectFlight; + initFlight = new Promise((resolve, reject) => { + resolveFlight = resolve; + rejectFlight = reject; + }); + const flight = initFlight; + initState = "starting"; + + // State is owned before wasm-bindgen reads caller-controlled input. A Proxy + // getter therefore cannot re-enter and start a second instance. + let pending; + try { + pending = initWasm(input); + } catch (error) { + initState = "idle"; + initFlight = undefined; + rejectFlight(error); + return flight; + } + initState = "async"; + Promise.resolve(pending).then( + () => { + initState = "ready"; + resolveFlight(); + }, + (error) => { + initState = "idle"; + initFlight = undefined; + rejectFlight(error); + }, + ); + return flight; +} + +export function initSync(input) { + if (initState === "ready") return; + if (initState === "async") { + throw new Error("Lab Colors: asynchronous initialization is in progress"); + } + if (initState === "starting") { + throw new Error("Lab Colors: initialization input admission is in progress"); + } + if (initState === "sync") { + throw new Error("Lab Colors: synchronous initialization is in progress"); + } + + initState = "sync"; + try { + initWasmSync(input); + initState = "ready"; + } catch (error) { + initState = "idle"; + throw error; + } +} + +export default init; + export { applyTheme } from "./apply-theme.js"; export { watchTheme } from "./watch-theme.js"; export { adaptTheme } from "./adapt-theme.js"; diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 3229b6af..74e49bf7 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -4,9 +4,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { initSync } from "../pkg/labcolors.js"; import { adaptTheme } from "../adapt-theme.js"; +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + // A fake LabColors engine. `resolveTheme` returns a controllable role set; // `recheckContrast` returns controllable signed Lc per role (interleaved with a // dummy wcag). Records call counts. diff --git a/packages/colors/test/chain-invariants.test.mjs b/packages/colors/test/chain-invariants.test.mjs index b2325bc1..1374e272 100644 --- a/packages/colors/test/chain-invariants.test.mjs +++ b/packages/colors/test/chain-invariants.test.mjs @@ -3,8 +3,8 @@ // потребителя (parseCssColor) → перепроверка легальности (recheckContrast). // // Почему здесь, а не в Rust: `vars[--lab-*]` — это ровно та строка, которую -// прочитает браузер, а `parseCssColor` / `compositeOver` / `toHex` — тот самый -// код пакета, что реконструирует цвет на странице (его же использует +// прочитает браузер, а `parseCssColor` и скрытый exact point bridge — тот самый +// путь пакета, что реконструирует цвет на странице (его же использует // effectiveBackground). Так тест меряет ПОТЕРИ НА СЕРИАЛИЗАЦИИ ВЫХОДА, а не // внутри солвера, и без параллельной копии физики контраста. // @@ -25,9 +25,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { initSync, LabColors } from "../pkg/labcolors.js"; +import { + initSync, + LabColors, + __over, +} from "../pkg/labcolors.js"; import { applyTheme } from "../apply-theme.js"; -import { parseCssColor, compositeOver, toHex } from "../effective-bg.js"; +import { parseCssColor, toHex } from "../effective-bg.js"; // Инициализация wasm в node: pkg собран под `--target web` (fetch по URL), а в // node грузим байты напрямую. Оборачиваем в WebAssembly.Module и передаём @@ -57,6 +61,12 @@ function engine() { // [r,g,b] из parseCssColor-результата (отбрасываем α). const rgb = (parsed) => [parsed[0], parsed[1], parsed[2]]; +const packRgb24 = (parsed) => + ((Math.round(parsed[0]) << 16) | + (Math.round(parsed[1]) << 8) | + Math.round(parsed[2])) >>> 0; +const hexFromRgb24 = (packed) => + `#${packed.toString(16).padStart(6, "0").toUpperCase()}`; // ───────────────────────────────────────────────────────────────────────────── // ЛЕГАЛЬНОСТЬ НАСКВОЗЬ — сплошные роли @@ -163,7 +173,13 @@ test("translucent serialization fidelity: emitted tint+alpha, reference composit // Reference-композит из эмитированных значений обязан совпасть с // сертификатом побайтно: допуск скрыл бы другой цвет и другие метрики. - const compHex = toHex(compositeOver(parsed, [bgParsed[0], bgParsed[1], bgParsed[2], 1])); + const packed = __over( + packRgb24(parsed), + alpha, + packRgb24(bgParsed), + ); + assert.notEqual(packed, 0xFFFFFFFF, "admitted emitted layer must compose"); + const compHex = hexFromRgb24(packed); assert.equal( compHex, role.compositeHex, diff --git a/packages/colors/test/init-lifecycle.test.mjs b/packages/colors/test/init-lifecycle.test.mjs new file mode 100644 index 00000000..6d1883cc --- /dev/null +++ b/packages/colors/test/init-lifecycle.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import init, { initSync, LabColors } from "../index.js"; + +test("public initialization has one owner across async and sync routes", async () => { + const module = new WebAssembly.Module( + readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url)), + ); + assert.throws( + () => initSync({ module: new Uint8Array([0]) }), + WebAssembly.CompileError, + ); + await assert.rejects( + init({ module_or_path: new Uint8Array([0]) }), + WebAssembly.CompileError, + ); + + const reentrantInput = Object.create(Object.prototype, { + module_or_path: { + enumerable: true, + get() { + return init({ module_or_path: module }); + }, + }, + }); + const reentrantOutcome = await Promise.race([ + init(reentrantInput).then( + () => "resolved", + (error) => error, + ), + new Promise((resolve) => setTimeout(() => resolve("still-pending"), 25)), + ]); + assert.notEqual(reentrantOutcome, "still-pending"); + assert.match(reentrantOutcome.message, /input admission is in progress/u); + + let release; + const delayed = new Promise((resolve) => { + release = resolve; + }); + + const first = init({ module_or_path: delayed }); + const second = init({ module_or_path: delayed }); + assert.equal(first, second, "concurrent async callers must share one flight"); + assert.throws( + () => initSync({ module }), + /asynchronous initialization is in progress/u, + ); + + release(module); + await first; + assert.equal(await second, undefined); + assert.equal(initSync({ module }), undefined, "ready initialization is idempotent"); + + const engine = new LabColors(); + engine.free(); +}); diff --git a/packages/colors/test/oklch-parse.test.mjs b/packages/colors/test/oklch-parse.test.mjs index 3e881418..9cc451f1 100644 --- a/packages/colors/test/oklch-parse.test.mjs +++ b/packages/colors/test/oklch-parse.test.mjs @@ -11,8 +11,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; -import { parseCssColor, effectiveBackground, compositeOver, toHex } from "../effective-bg.js"; +import { initSync } from "../pkg/labcolors.js"; +import { parseCssColor, effectiveBackground } from "../effective-bg.js"; + +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); // --- Live-emitted fixtures ------------------------------------------------- // @@ -93,13 +99,12 @@ test("Chrome computed form: L as a 0..1 number parses, and equals the percentage test("effectiveBackground composites oklch layers (translucent over opaque)", () => { // A translucent white oklch panel over an opaque near-black oklch base — the - // exact self-composed case the package produces. Oracle is independent of - // oklch parsing: compositeOver + toHex on the KNOWN source bytes. + // exact self-composed case the package produces. The known byte arithmetic + // is `26 + .5 × (255 - 26) = 140.5`, round-half-up → 141 (`#8D8D8D`). const leaf = "oklch(100.00000% 0.000000 89.876 / 0.5)"; // #FFFFFF @ 0.5 const base = "oklch(21.77865% 0.000000 89.876)"; // #1A1A1A opaque const tree = fakeTree([leaf, base]); - const expected = toHex(compositeOver([255, 255, 255, 0.5], [26, 26, 26, 1])); - assert.equal(effectiveBackground(tree.leaf, tree), expected); + assert.equal(effectiveBackground(tree.leaf, tree), "#8D8D8D"); }); test("component forms: none = 0, chroma as a percentage (100% = 0.4), deg suffix on hue", () => { diff --git a/packages/colors/test/point-composition.test.mjs b/packages/colors/test/point-composition.test.mjs new file mode 100644 index 00000000..4c26e4fb --- /dev/null +++ b/packages/colors/test/point-composition.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { + initSync, + __over, +} from "../pkg/labcolors.js"; + +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + +const INVALID_RGB24 = 0xFFFFFFFF; + +test("hidden point bridge preserves the byte-domain half tie", () => { + assert.equal( + __over(0xC0B2FA, 0.122, 0x000000), + 0x17161F, + ); +}); + +test("hidden point bridge preserves the declared affine operation order", () => { + const alphas = [ + 0.81299212598425186, + 0.81299212598425197, + 0.81299212598425208, + ]; + assert.deepEqual( + alphas.map((alpha) => + (__over(0xFF0000, alpha, 0x010000) >> 16) & 0xFF, + ), + [207, 208, 208], + ); +}); + +test("hidden point bridge matches an independent rational oracle on every byte pair", () => { + let comparisons = 0; + for (let source = 0; source <= 255; source++) { + for (let backdrop = 0; backdrop <= 255; backdrop++) { + const actual = + __over(source << 16, 0.122, backdrop << 16) >> 16; + const expected = Math.floor((122 * source + 878 * backdrop + 500) / 1000); + assert.equal(actual, expected, `source=${source}, backdrop=${backdrop}`); + comparisons++; + } + } + assert.equal(comparisons, 65_536, "full single-channel domain must be exercised"); +}); + +test("hidden point bridge has one invalid-opacity rejection channel", () => { + for (const opacity of [NaN, -Infinity, -0.1, 1.1, Infinity]) { + assert.equal(__over(0, opacity, 0), INVALID_RGB24); + } + assert.equal(__over(0, -0, 0x123456), 0x123456); +}); diff --git a/packages/colors/test/public-api-cleanup.test.mjs b/packages/colors/test/public-api-cleanup.test.mjs index 51a34c81..a6aa2614 100644 --- a/packages/colors/test/public-api-cleanup.test.mjs +++ b/packages/colors/test/public-api-cleanup.test.mjs @@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { test } from "node:test"; +import * as publicRoot from "../index.js"; + const ROOT = resolve(import.meta.dirname, "../../.."); const read = (...parts) => readFileSync(join(ROOT, ...parts), "utf8"); @@ -10,6 +12,7 @@ test("effective-background math stays internal to the browser shell", () => { const manifest = JSON.parse(read("packages", "colors", "package.json")); const rootRuntime = read("packages", "colors", "index.js"); const rootTypes = read("packages", "colors", "index.d.ts"); + const backdropRuntime = read("packages", "colors", "effective-bg.js"); const releaseVerifier = read("scripts", "verify-package-release.mjs"); assert.equal(manifest.exports["./effective-bg"], undefined); @@ -22,6 +25,7 @@ test("effective-background math stays internal to the browser shell", () => { "compositeStackToHex", "toHex", "oklabLerp", + "__over", ]) { assert.doesNotMatch(rootRuntime, new RegExp(`\\b${name}\\b`, "u")); assert.doesNotMatch(rootTypes, new RegExp(`\\b${name}\\b`, "u")); @@ -30,6 +34,23 @@ test("effective-background math stays internal to the browser shell", () => { manifest.files.includes("effective-bg.js"), "watch/adapt still need the internal estimate until occurrence cutover", ); + assert.equal(manifest.exports["./pkg/labcolors.js"], undefined); + assert.match(backdropRuntime, /__over/u); + assert.doesNotMatch( + backdropRuntime, + /export function compositeOver|function compositeOver|compositeStackToHex/u, + ); +}); + +test("public initialisation cannot leak raw WASM exports", () => { + const result = publicRoot.initSync({ + module: new WebAssembly.Module( + readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url)), + ), + }); + + assert.equal(result, undefined); + assert.equal(publicRoot.__over, undefined); }); test("the parse memo never exposes its shared cache entry", async () => { diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index e3b33db8..56eda804 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -1765,6 +1765,11 @@ test("runtime declarations expose one curated type surface", () => { [...generatedNames].sort(), "root types must equal the runtime generated surface exactly", ); + assert.doesNotMatch( + rootDeclarations, + /^export (?:declare )?(?:type|interface|class|enum|namespace)\s+[A-Za-z]/mu, + "root declarations must not add local named types beside the curated re-export blocks", + ); assert.doesNotMatch(rootDeclarations, /Feasibility|feasibility/u); assert.match(rootDeclarations, /export type \{ Wcag22CriterionV1 \} from "\.\/wcag22\.js"/u); diff --git a/packages/colors/test/runtime.test.mjs b/packages/colors/test/runtime.test.mjs index 8481b264..1ad75ee7 100644 --- a/packages/colors/test/runtime.test.mjs +++ b/packages/colors/test/runtime.test.mjs @@ -5,19 +5,36 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { runInNewContext } from "node:vm"; +import { initSync } from "../pkg/labcolors.js"; import { parseCssColor, - compositeOver, toHex, - compositeStackToHex, effectiveBackground, oklabLerp, } from "../effective-bg.js"; import { applyTheme } from "../apply-theme.js"; import { watchTheme } from "../watch-theme.js"; +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + +// Test-only continuous oracle for the legacy Material evidence below. Runtime +// source-over lives exclusively in Core; this function is deliberately not +// imported by production code and does not stand in for emitted sRGB8 bytes. +function continuousSourceOverOracle(top, bottom) { + const alpha = bottom[3] + top[3] * (1 - bottom[3]); + if (alpha === 0) return [0, 0, 0, 0]; + const channel = (index) => { + const premultipliedBottom = bottom[index] * bottom[3]; + return (premultipliedBottom + top[3] * (top[index] - premultipliedBottom)) / alpha; + }; + return [channel(0), channel(1), channel(2), alpha]; +} + function captureOutputConflict(fn, expectedConflicts) { let error = null; try { @@ -213,32 +230,18 @@ test("parseCssColor handles the forms computed style yields", () => { assert.equal(parseCssColor("rebeccapurple"), null); // unknown keyword → no layer assert.equal(parseCssColor(""), null); assert.equal(parseCssColor(42), null); -}); - -test("compositeOver is true source-over alpha", () => { - // Opaque over anything → the top colour. - assert.deepEqual(compositeOver([10, 20, 30, 1], [200, 200, 200, 1]), [10, 20, 30, 1]); - // 50% black over white → mid grey, opaque. - const r = compositeOver([0, 0, 0, 0.5], [255, 255, 255, 1]); - assert.equal(Math.round(r[0]), 128); - 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)); + for (const malformed of [ + "#FZFFFF", + "rgb(1x 2 3)", + "rgb(1e999 2 3)", + "rgb(1,, 2, 3)", + "rgb(1 2 3 0.5)", + "rgb(1 2 3 / 0.5 extra)", + "oklch(50% 1e308 0)", + "oklch(50% 0.1 1e308)", + ]) { + assert.equal(parseCssColor(malformed), null, malformed); + } }); test("material alpha rechecks in the declared byte-scale affine legacy-WCAG profile", () => { @@ -275,9 +278,9 @@ test("material alpha rechecks in the declared byte-scale affine legacy-WCAG prof }, ]) { const bottom = [255, 255, 255, 1]; - const oldContrast = contrastAgainstWhite(compositeOver([...tint, oldAlpha], bottom)); + const oldContrast = contrastAgainstWhite(continuousSourceOverOracle([...tint, oldAlpha], bottom)); const selectedContrast = contrastAgainstWhite( - compositeOver([...tint, selectedAlpha], bottom), + continuousSourceOverOracle([...tint, selectedAlpha], bottom), ); assert.ok(oldContrast < floor, `old ${oldContrast} must miss ${floor}`); assert.ok(selectedContrast >= floor, `selected ${selectedContrast} must hold ${floor}`); @@ -293,10 +296,10 @@ test("material alpha rechecks in the declared byte-scale affine legacy-WCAG prof const interiorByte = 0.9997624803942831 * 255; const interior = [interiorByte, interiorByte, interiorByte, 1]; const oldInteriorContrast = contrastAgainstWhite( - compositeOver([0, 0, 0, oldSeamAlpha], interior), + continuousSourceOverOracle([0, 0, 0, oldSeamAlpha], interior), ); const selectedInteriorContrast = contrastAgainstWhite( - compositeOver([0, 0, 0, selectedSeamAlpha], interior), + continuousSourceOverOracle([0, 0, 0, selectedSeamAlpha], interior), ); assert.ok( oldInteriorContrast < seamFloor, @@ -316,7 +319,7 @@ test("toHex rounds and clamps", () => { test("toHex coerces non-finite channels to 0 (valid CSS, never #NAN…)", () => { // A malformed Rgba (NaN/Infinity channels) must still yield a valid #RRGGBB, - // not an invalid CSS string. Reachable via the public toHex/compositeStackToHex. + // not an invalid CSS string. assert.equal(toHex([NaN, 0, 0]), "#000000"); assert.equal(toHex([Infinity, 128, -Infinity]), "#008000"); // any non-finite → 0 (not clamped) assert.equal(toHex([undefined, 255, 255]), "#00FFFF"); @@ -369,11 +372,54 @@ test("oklabLerp falls back to the valid endpoint on unparseable input", () => { assert.equal(oklabLerp("#123456", "garbage", 0.7), "#123456"); }); -test("compositeStackToHex composites front-to-back over an opaque base", () => { - // 50% black panel over white base → #808080. - assert.equal(compositeStackToHex([[0, 0, 0, 0.5]], [255, 255, 255, 1]), "#808080"); - // Empty stack → the base itself. - assert.equal(compositeStackToHex([], [18, 18, 22, 1]), "#121216"); +test("effective background rounds every declared point occurrence", () => { + const { leaf, getStyle, parentOf } = fakeTree([ + "rgba(0, 0, 0, 0.5)", + "rgba(1, 0, 0, 0.5)", + "rgb(0, 0, 0)", + ]); + // Point-граф материализует нижний occurrence в байт 1, затем верхний снова + // в байт 1. Старый JS-stack сохранял дробный 0.5 между рёбрами и округлял + // только общий итог 0.25 до нуля — это была другая физическая программа. + assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#010000"); +}); + +test("effective background preserves front-to-back layer order", () => { + const { leaf, getStyle, parentOf } = fakeTree([ + "rgba(255, 0, 0, 0.5)", + "rgba(0, 0, 255, 0.5)", + "rgb(0, 0, 0)", + ]); + assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#800040"); +}); + +test("effective background quantises fractional CSS channels by nearest byte", () => { + const { leaf, getStyle, parentOf } = fakeTree(["rgb(0.5 127.5 254.5)"]); + assert.equal(effectiveBackground(leaf, { getStyle, parentOf }), "#0180FF"); +}); + +test("effective background never reinterprets a translucent fallback as opaque", () => { + const { leaf, getStyle, parentOf } = fakeTree(["rgba(0, 0, 0, 0.5)"]); + + assert.throws( + () => effectiveBackground(leaf, { + fallback: "rgba(255, 0, 0, 0.5)", + getStyle, + parentOf, + }), + /fallback must be an opaque supported colour/u, + ); + for (const fallback of ["#FZFFFF", "oklch(50% 1e308 0)", "oklch(50% 0.1 1e308)"]) { + assert.throws( + () => effectiveBackground(leaf, { + fallback, + getStyle, + parentOf, + }), + /fallback must be an opaque supported colour/u, + fallback, + ); + } }); // A tiny fake element tree for effectiveBackground: each node carries a diff --git a/packages/colors/watch-theme.d.ts b/packages/colors/watch-theme.d.ts index c071d324..30aacee3 100644 --- a/packages/colors/watch-theme.d.ts +++ b/packages/colors/watch-theme.d.ts @@ -16,7 +16,7 @@ export interface WatchThemeOptions { background?: string | (() => string); /** Element to write the `--lab-*` variables onto. Defaults to the watched element. */ target?: HTMLElement; - /** Base colour when the ancestor chain is fully translucent. Default `"#FFFFFF"`. */ + /** Непрозрачная поддерживаемая база полностью прозрачной цепочки. По умолчанию `"#FFFFFF"`. */ fallback?: string; /** Auto-refresh on `style`/`class` attribute changes in the observed subtree. Default `true`. */ observe?: boolean; diff --git a/packages/colors/watch-theme.js b/packages/colors/watch-theme.js index fe3b932a..44bea72b 100644 --- a/packages/colors/watch-theme.js +++ b/packages/colors/watch-theme.js @@ -48,7 +48,7 @@ const CANCELLED = Symbol("watchTheme.cancelled"); * When supplied, it must be a non-empty string; invalid explicit evidence is * rejected instead of being reinterpreted as the omitted-input fallback. * @param {*} [options.target=element] Element to write the variables onto. - * @param {string} [options.fallback="#FFFFFF"] Base for a fully-translucent chain. + * @param {string} [options.fallback="#FFFFFF"] Opaque supported base for a fully-translucent chain. * @param {boolean} [options.observe=true] Auto-refresh on `style`/`class` * attribute changes in the observed subtree. * @param {(error: unknown) => void} [options.onError] Receives failures from diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 9e8ff0c4..30e840a3 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "96b15e2dcb9b4c41439526809c830c50dda50f716d33c885d9b6b7b89dabb300"; + "e01d8055e884bad5377af58cdc6aa1bf232a3f30f93abc250220d4947a7187c9"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm";