diff --git a/SLASHED-for-WP/includes/class-color-resolver.php b/SLASHED-for-WP/includes/class-color-resolver.php index 5be49cf..b287a31 100644 --- a/SLASHED-for-WP/includes/class-color-resolver.php +++ b/SLASHED-for-WP/includes/class-color-resolver.php @@ -172,6 +172,10 @@ public static function resolve( $color_values ) { // Semantic tokens with reasonable light-mode defaults. $hex_map = self::resolve_semantic_tokens( $hex_map, $sources ); + // Remaining picker tokens (per-family sources, white/black, caret, …). + $dark_sources = self::derive_dark_sources( $sources, $color_values ); + $hex_map = self::add_picker_only_tokens( $hex_map, $sources, $dark_sources, 'light' ); + return $hex_map; } @@ -205,9 +209,132 @@ public static function resolve_dark( $color_values ) { $hex_map = self::build_family_scales( $dark_sources, Slashed_Color_Math::hex_to_rgb( $base_dark_hex ) ); $hex_map = self::resolve_semantic_tokens_dark( $hex_map, $dark_sources, $light_sources ); + // Remaining picker tokens (per-family sources, white/black, caret, …). + $hex_map = self::add_picker_only_tokens( $hex_map, $light_sources, $dark_sources, 'dark' ); + return $hex_map; } + /** + * Emit the colour tokens the framework ships in its variable picker that the + * per-family scale and semantic passes don't already produce, so every + * `--sf-color-*` entry in the Bricks dropdown gets a swatch. + * + * Covered here: + * - the raw per-family SOURCE tokens (`-source-light` / `-source-dark`) — + * absolute values, so both maps carry the same hex regardless of the + * page mode being previewed; + * - the literal `white` / `black`; + * - `caret` (the framework aliases it to `--sf-color-action`); + * - the alt-selection pair (`selection-bg--alt` / `selection-text--alt`), + * approximated by the same-scheme selection swatch — both are an + * action-tinted highlight, adequate for a preview square; + * - `text--subtle`, derived from the mode-appropriate neutral source to + * mirror core/tokens.css. + * + * Called by both resolvers with the same light/dark source sets so the two + * maps expose an identical key set (see the light/dark parity test). + * + * @param array $hex_map Map built so far. + * @param array $light_sources Family => [L, C, H] (light). + * @param array $dark_sources Family => [L, C, H] (dark). + * @param string $mode 'light' or 'dark' — selects the neutral used for text--subtle. + * @return array + */ + private static function add_picker_only_tokens( $hex_map, $light_sources, $dark_sources, $mode ) { + // Per-family source tokens. Absolute (mode-independent) values, so the + // light and dark maps carry the same hex for each. + foreach ( $light_sources as $family => $lch ) { + $hex_map[ '--sf-color-' . $family . '-source-light' ] = Slashed_Color_Math::oklch_to_hex( $lch[0], $lch[1], $lch[2] ); + } + foreach ( $dark_sources as $family => $lch ) { + $hex_map[ '--sf-color-' . $family . '-source-dark' ] = Slashed_Color_Math::oklch_to_hex( $lch[0], $lch[1], $lch[2] ); + } + + // Literal white / black (oklch(100% 0 0) / oklch(0% 0 0)). + $hex_map['--sf-color-white'] = '#ffffff'; + $hex_map['--sf-color-black'] = '#000000'; + + // caret aliases action. + if ( isset( $hex_map['--sf-color-action'] ) ) { + $hex_map['--sf-color-caret'] = $hex_map['--sf-color-action']; + } + + // Alt selection background — the OPPOSITE scheme's treatment (see + // core/tokens.css, where the light/dark branches are swapped relative to + // --sf-color-selection-bg): in light mode it uses the dark selection + // formula composited over the light page; in dark mode the light formula + // over the dark surface. Approximated to an opaque swatch. + $alt_bg = self::resolve_alt_selection_bg( $hex_map, $light_sources, $dark_sources, $mode ); + if ( null !== $alt_bg ) { + $hex_map['--sf-color-selection-bg--alt'] = $alt_bg; + } + // selection-text--alt is `inherit` in the framework → the current text + // colour, which the resolver models as --sf-color-selection-text. + if ( isset( $hex_map['--sf-color-selection-text'] ) ) { + $hex_map['--sf-color-selection-text--alt'] = $hex_map['--sf-color-selection-text']; + } + + // text--subtle — derived from the mode-appropriate neutral source, + // mirroring the clamp() formulas in core/tokens.css. (contrast-bias is 0 + // by default, matching the rest of this resolver.) + $neutral = ( 'dark' === $mode ) + ? ( isset( $dark_sources['neutral'] ) ? $dark_sources['neutral'] : null ) + : ( isset( $light_sources['neutral'] ) ? $light_sources['neutral'] : null ); + if ( null !== $neutral ) { + list( $nl, $nc, $nh ) = $neutral; + $l = ( 'dark' === $mode ) + ? max( 0.55, min( $nl + 0.1, 0.90 ) ) + : max( 0.15, min( $nl - 0.25, 0.45 ) ); + $hex_map['--sf-color-text--subtle'] = Slashed_Color_Math::oklch_to_hex( $l, $nc, $nh ); + } else { + $hex_map['--sf-color-text--subtle'] = ( 'dark' === $mode ) ? '#9a9aae' : '#3a3a4d'; + } + + return $hex_map; + } + + /** + * Resolve the alternate selection background swatch for one mode. + * + * Mirrors core/tokens.css, where `--sf-color-selection-bg--alt` deliberately + * inverts the scheme of `--sf-color-selection-bg`: + * - light page → the DARK formula: action dark-source at clamp(0.62, + * 0.93 - l*0.4, 0.78) lightness, 55% over the (white) page; + * - dark page → the LIGHT formula: action light-source at 28% over the + * dark surface. + * The translucent CSS value is approximated as an opaque composite, matching + * how the rest of this resolver previews alpha tokens. + * + * @param array $hex_map Map built so far (used for the dark surface). + * @param array $light_sources Family => [L, C, H] (light). + * @param array $dark_sources Family => [L, C, H] (dark). + * @param string $mode 'light' or 'dark'. + * @return string|null Hex string, or null when the action source is missing. + */ + private static function resolve_alt_selection_bg( $hex_map, $light_sources, $dark_sources, $mode ) { + if ( 'light' === $mode ) { + if ( ! isset( $dark_sources['action'] ) ) { + return null; + } + list( $dl, $dc, $dh ) = $dark_sources['action']; + $l = max( 0.62, min( 0.93 - $dl * 0.4, 0.78 ) ); + $rgb = Slashed_Color_Math::hex_to_rgb( Slashed_Color_Math::oklch_to_hex( $l, $dc, $dh ) ); + return Slashed_Color_Math::rgb_to_hex( Slashed_Color_Math::mix_rgb( $rgb, array( 255, 255, 255 ), 0.55 ) ); + } + + if ( ! isset( $light_sources['action'] ) ) { + return null; + } + list( $ll, $lc, $lh ) = $light_sources['action']; + $rgb = Slashed_Color_Math::hex_to_rgb( Slashed_Color_Math::oklch_to_hex( $ll, $lc, $lh ) ); + $surface_hex = isset( $hex_map['--sf-color-surface'] ) + ? $hex_map['--sf-color-surface'] + : ( isset( $hex_map['--sf-color-base'] ) ? $hex_map['--sf-color-base'] : '#1a1b1e' ); + $surface_rgb = Slashed_Color_Math::hex_to_rgb( $surface_hex ); + return Slashed_Color_Math::rgb_to_hex( Slashed_Color_Math::mix_rgb( $rgb, $surface_rgb, 0.28 ) ); + } + /** * Build the per-family scale/alpha/alias hex map from resolved sources. * diff --git a/tests-php/ColorResolverTest.php b/tests-php/ColorResolverTest.php index beed1c1..6baf6d1 100644 --- a/tests-php/ColorResolverTest.php +++ b/tests-php/ColorResolverTest.php @@ -125,6 +125,98 @@ public function test_state_modifier_aliases_use_the_double_dash_bem_naming() { } } + public function test_picker_only_tokens_are_resolved_in_both_modes() { + // Regression guard: these tokens ship in the framework's variable picker + // but are not produced by the per-family scale or the semantic passes. + // Without them the Bricks colour dropdown rendered those rows swatch-less. + $maps = array( + 'light' => Slashed_Color_Resolver::resolve( array() ), + 'dark' => Slashed_Color_Resolver::resolve_dark( array() ), + ); + $expected = array( + // Per-family raw source tokens. + '--sf-color-primary-source-light', + '--sf-color-primary-source-dark', + '--sf-color-action-source-light', + '--sf-color-action-source-dark', + '--sf-color-base-source-light', + '--sf-color-base-source-dark', + // Literals, caret alias, alt selection, subtle text. + '--sf-color-white', + '--sf-color-black', + '--sf-color-caret', + '--sf-color-selection-bg--alt', + '--sf-color-selection-text--alt', + '--sf-color-text--subtle', + ); + foreach ( $maps as $mode => $map ) { + foreach ( $expected as $key ) { + $this->assertArrayHasKey( $key, $map, "$mode map must resolve $key" ); + } + } + } + + public function test_source_tokens_are_mode_independent_and_match_the_family_base() { + // A -source-light / -source-dark token is an absolute input value, so it + // reads the same in the light and dark maps; -source-light equals the + // LIGHT family base and -source-dark equals the DARK family base. + $light = Slashed_Color_Resolver::resolve( array() ); + $dark = Slashed_Color_Resolver::resolve_dark( array() ); + + // Cover EVERY family the resolver actually emits a source token for + // (derived from the map so a newly-added family is covered automatically). + $families = array(); + foreach ( array_keys( $light ) as $key ) { + if ( preg_match( '/^--sf-color-(.+)-source-light$/', $key, $m ) ) { + $families[] = $m[1]; + } + } + $this->assertNotEmpty( $families, 'resolver must emit per-family source tokens' ); + + foreach ( $families as $family ) { + $sl = '--sf-color-' . $family . '-source-light'; + $sd = '--sf-color-' . $family . '-source-dark'; + $this->assertArrayHasKey( $sd, $light, "$family must also expose -source-dark" ); + + $this->assertSame( $light[ $sl ], $dark[ $sl ], "$family -source-light must be mode-independent" ); + $this->assertSame( $light[ $sd ], $dark[ $sd ], "$family -source-dark must be mode-independent" ); + + $this->assertSame( $light[ '--sf-color-' . $family ], $light[ $sl ], "$family -source-light must equal the light family base" ); + $this->assertSame( $dark[ '--sf-color-' . $family ], $dark[ $sd ], "$family -source-dark must equal the dark family base" ); + } + } + + public function test_caret_and_literals_resolve_as_expected() { + $light = Slashed_Color_Resolver::resolve( array() ); + $this->assertSame( '#ffffff', $light['--sf-color-white'] ); + $this->assertSame( '#000000', $light['--sf-color-black'] ); + $this->assertSame( $light['--sf-color-action'], $light['--sf-color-caret'], 'caret aliases action' ); + } + + public function test_alt_selection_bg_uses_the_opposite_mode_formula() { + // --sf-color-selection-bg--alt inverts the scheme of --sf-color-selection-bg + // (see core/tokens.css), so it must NOT simply copy the same-mode value. + $light = Slashed_Color_Resolver::resolve( array() ); + $dark = Slashed_Color_Resolver::resolve_dark( array() ); + + $this->assertArrayHasKey( '--sf-color-selection-bg--alt', $light ); + $this->assertArrayHasKey( '--sf-color-selection-bg--alt', $dark ); + $this->assertNotSame( + $light['--sf-color-selection-bg'], + $light['--sf-color-selection-bg--alt'], + 'alt selection must differ from the same-mode selection background' + ); + + // Exact-value guard: recompute the light-mode alt (the dark formula — + // action dark-source clamped, 55% over white) from the public primitives. + $dark_action = Slashed_Color_Math::parse_oklch( 'oklch(0.70 0.198 235)' ); // action -source-dark default + list( $dl, $dc, $dh ) = $dark_action; + $l = max( 0.62, min( 0.93 - $dl * 0.4, 0.78 ) ); + $rgb = Slashed_Color_Math::hex_to_rgb( Slashed_Color_Math::oklch_to_hex( $l, $dc, $dh ) ); + $expected = Slashed_Color_Math::rgb_to_hex( Slashed_Color_Math::mix_rgb( $rgb, array( 255, 255, 255 ), 0.55 ) ); + $this->assertSame( $expected, $light['--sf-color-selection-bg--alt'], 'light alt selection uses the dark formula over white' ); + } + public function test_light_and_dark_resolve_the_same_variable_set() { $light = array_keys( Slashed_Color_Resolver::resolve( array() ) ); $dark = array_keys( Slashed_Color_Resolver::resolve_dark( array() ) );