From 3a515d694760094becddb9dba8da36ab6cafa6ae Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:04:18 +0000 Subject: [PATCH 1/2] fix: order variable dropdowns by scale instead of alphabetically Variables grouped per category were sorted with a plain natural sort, so scale families rendered in a lexicographic jumble (2xl, 2xs, 3xl, l, m, s, xl, xs) instead of small->large. This affected both the Bricks variable picker (injected into bricks_global_variables) and the Gutenberg token panel, which share Slashed_Inventory::get_variables_by_category(). Add a scale-aware comparator to Slashed_Category_Map (scale_order() + compare()/split_scale()) that groups tokens by base, orders the t-shirt scale (none, px, 2xs, xs, s, m, l, xl, 2xl, 3xl, 4xl...) by rank, and keeps numeric colour steps (50..950) numeric. get_variables_by_category() now sorts each category with usort() using this comparator. Fixes #232 --- .../includes/class-category-map.php | 131 ++++++++++++++++++ SLASHED-for-WP/includes/class-inventory.php | 9 +- tests-php/CategoryMapTest.php | 124 +++++++++++++++++ 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/SLASHED-for-WP/includes/class-category-map.php b/SLASHED-for-WP/includes/class-category-map.php index 77082fc6..24d4bece 100644 --- a/SLASHED-for-WP/includes/class-category-map.php +++ b/SLASHED-for-WP/includes/class-category-map.php @@ -179,4 +179,135 @@ public static function label_for( $first_segment ) { $map = self::map(); return isset( $map[ $first_segment ] ) ? $map[ $first_segment ] : null; } + + /** + * Semantic ordering rank for a token's trailing scale keyword. + * + * Design tokens use a t-shirt scale (2xs → 7xl) plus a handful of edge + * keywords (none, px, base, full, max). A plain alphabetical / natural + * sort renders these in the wrong visual order — e.g. Spacing comes out as + * 2xl, 2xs, 3xl, l, m, s, xl, xs — because "2xl" sorts before "2xs" and the + * single letters land wherever the alphabet puts them. This map assigns + * each keyword a rank so a comparator can restore the intended small→large + * progression (2xs, xs, s, m, l, xl, 2xl, 3xl …). + * + * Lower rank sorts earlier. Keywords absent from this map are treated as + * non-scale tokens by {@see compare()}. + * + * @return array + */ + public static function scale_order() { + return array( + 'none' => 0, + 'px' => 1, + '4xs' => 10, + '3xs' => 11, + '2xs' => 12, + 'xs' => 13, + 'sm' => 14, + 's' => 15, + 'base' => 16, + 'md' => 17, + 'm' => 18, + 'lg' => 19, + 'l' => 20, + 'xl' => 21, + '2xl' => 22, + '3xl' => 23, + '4xl' => 24, + '5xl' => 25, + '6xl' => 26, + '7xl' => 27, + 'full' => 40, + 'max' => 41, + ); + } + + /** + * Compare two --sf-* variable names for semantic (scale-aware) ordering. + * + * Names are first grouped by their "base" (the name with any trailing + * scale keyword or numeric step removed), then, within a base, ordered by + * scale rank (see {@see scale_order()}) or numeric step. This keeps a + * family such as Spacing in small→large order (--sf-space-2xs, -xs, -s, -m, + * -l, -xl, -2xl …) and colour steps in numeric order (--sf-color-primary-50, + * -100, …, -950) instead of the lexicographic jumble a plain sort() + * produces. Non-scale tokens keep their natural, case-insensitive order. + * + * Suitable as the callback for usort(). + * + * @param string $a First variable name (including leading "--"). + * @param string $b Second variable name. + * @return int Negative, zero, or positive per the usort() contract. + */ + public static function compare( $a, $b ) { + $pa = self::split_scale( (string) $a ); + $pb = self::split_scale( (string) $b ); + + // Different families/bases: fall back to natural, case-insensitive + // order so category members stay grouped the way they always were. + $base_cmp = strnatcasecmp( $pa['base'], $pb['base'] ); + if ( 0 !== $base_cmp ) { + return $base_cmp; + } + + // Same base: order by semantic rank (scale keyword or numeric step). + if ( $pa['rank'] !== $pb['rank'] ) { + return ( $pa['rank'] < $pb['rank'] ) ? -1 : 1; + } + + // Identical rank (e.g. two unrelated non-scale tokens): stable, + // natural, case-insensitive tie-break on the full names. + return strnatcasecmp( (string) $a, (string) $b ); + } + + /** + * Split a variable name into its scale "base" and a numeric ordering rank. + * + * The trailing "-{segment}" is inspected: a known scale keyword yields its + * {@see scale_order()} rank; a purely numeric segment yields that integer + * offset past the keyword band (so 50 < 100 < 950 and a bare base token + * still sorts before its numbered steps); anything else is treated as a + * non-scale token whose base is the full name and whose rank is 0. + * + * @param string $name Full variable name. + * @return array{base: string, rank: int} + */ + private static function split_scale( $name ) { + $dash = strrpos( $name, '-' ); + if ( false === $dash || strlen( $name ) - 1 === $dash ) { + return array( + 'base' => $name, + 'rank' => 0, + ); + } + + $suffix = substr( $name, $dash + 1 ); + $base = substr( $name, 0, $dash ); + + $scale = self::scale_order(); + $key = strtolower( $suffix ); + if ( isset( $scale[ $key ] ) ) { + return array( + 'base' => $base, + 'rank' => $scale[ $key ], + ); + } + + // Numeric step (colour scales: -50, -100, … -950). Offset past the + // keyword-rank band so a bare base token still sorts before its steps. + if ( '' !== $suffix && ctype_digit( $suffix ) ) { + return array( + 'base' => $base, + 'rank' => 100 + (int) $suffix, + ); + } + + // Non-scale token: keep the full name as its own base so unrelated + // tokens simply fall back to natural ordering against each other. + return array( + 'base' => $name, + 'rank' => 0, + ); + } } diff --git a/SLASHED-for-WP/includes/class-inventory.php b/SLASHED-for-WP/includes/class-inventory.php index 44249f48..3c11db03 100644 --- a/SLASHED-for-WP/includes/class-inventory.php +++ b/SLASHED-for-WP/includes/class-inventory.php @@ -405,7 +405,10 @@ public static function get_is_classes() { * Get variables grouped by category label. * * Categories appear in canonical display order. Empty categories are - * dropped. Names within each category are sorted. + * dropped. Names within each category are ordered semantically by + * Slashed_Category_Map::compare() so scale families read small→large + * (2xs, xs, s, m, l, xl, 2xl …) and numeric colour steps stay numeric, + * rather than the lexicographic order a plain sort() produces. * * @return array */ @@ -422,7 +425,7 @@ public static function get_variables_by_category() { $ordered = array(); foreach ( Slashed_Category_Map::order() as $cat ) { if ( ! empty( $grouped[ $cat ] ) ) { - sort( $grouped[ $cat ], SORT_NATURAL | SORT_FLAG_CASE ); + usort( $grouped[ $cat ], array( 'Slashed_Category_Map', 'compare' ) ); $ordered[ $cat ] = $grouped[ $cat ]; } } @@ -430,7 +433,7 @@ public static function get_variables_by_category() { // Append any uncategorized buckets at the end (defensive). foreach ( $grouped as $cat => $list ) { if ( ! isset( $ordered[ $cat ] ) ) { - sort( $list, SORT_NATURAL | SORT_FLAG_CASE ); + usort( $list, array( 'Slashed_Category_Map', 'compare' ) ); $ordered[ $cat ] = $list; } } diff --git a/tests-php/CategoryMapTest.php b/tests-php/CategoryMapTest.php index 7ab9cc46..ef2805f4 100644 --- a/tests-php/CategoryMapTest.php +++ b/tests-php/CategoryMapTest.php @@ -69,4 +69,128 @@ public function test_every_mapped_label_appears_in_the_order_list() { $this->assertContains( $case[1], $order, "label '{$case[1]}' is produced by label_for() but missing from order()" ); } } + + /** + * The t-shirt scale (2xs → 3xl) must read small→large after sorting with + * compare(), not in the lexicographic jumble a plain sort() produced + * (2xl, 2xs, 3xl, l, m, s, xl, xs). This is the core of issue #232. + */ + public function test_compare_orders_tshirt_scale_small_to_large() { + $input = array( + '--sf-space-2xl', + '--sf-space-2xs', + '--sf-space-3xl', + '--sf-space-l', + '--sf-space-m', + '--sf-space-s', + '--sf-space-xl', + '--sf-space-xs', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + '--sf-space-2xs', + '--sf-space-xs', + '--sf-space-s', + '--sf-space-m', + '--sf-space-l', + '--sf-space-xl', + '--sf-space-2xl', + '--sf-space-3xl', + ), + $input + ); + } + + /** + * Edge keywords (none, px, base) slot into the scale, and non-scale config + * tokens (ratio, scale) sort after the scale members of the family. + */ + public function test_compare_places_edge_keywords_and_non_scale_tokens() { + $input = array( + '--sf-space-scale', + '--sf-space-m', + '--sf-space-none', + '--sf-space-px', + '--sf-space-xs', + '--sf-space-ratio', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + // none → px → xs → m are the scale members (base "--sf-space"). + '--sf-space-none', + '--sf-space-px', + '--sf-space-xs', + '--sf-space-m', + // Non-scale config tokens keep natural order, after the scale. + '--sf-space-ratio', + '--sf-space-scale', + ), + $input + ); + } + + /** + * Numeric colour steps must order numerically (50 < 100 < 950), with the + * bare family token ahead of its numbered steps. + */ + public function test_compare_orders_numeric_colour_steps_numerically() { + $input = array( + '--sf-color-primary-100', + '--sf-color-primary-50', + '--sf-color-primary-950', + '--sf-color-primary', + '--sf-color-primary-500', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + '--sf-color-primary', + '--sf-color-primary-50', + '--sf-color-primary-100', + '--sf-color-primary-500', + '--sf-color-primary-950', + ), + $input + ); + } + + /** + * Distinct families must stay grouped together (not interleaved) so the + * category list still reads one family at a time. + */ + public function test_compare_keeps_distinct_families_grouped() { + $input = array( + '--sf-size-xl', + '--sf-space-s', + '--sf-size-s', + '--sf-space-xl', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + '--sf-size-s', + '--sf-size-xl', + '--sf-space-s', + '--sf-space-xl', + ), + $input + ); + } + + public function test_scale_order_is_monotonic_across_the_tshirt_scale() { + $scale = Slashed_Category_Map::scale_order(); + $sequence = array( '2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl' ); + $prev = -1; + foreach ( $sequence as $key ) { + $this->assertArrayHasKey( $key, $scale ); + $this->assertGreaterThan( $prev, $scale[ $key ], "scale rank for '{$key}' must increase along the scale" ); + $prev = $scale[ $key ]; + } + } } From 452fcf58509fcfd42f62e6a8a075b90af9a5ca18 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:01:53 +0000 Subject: [PATCH 2/2] fix: compare variables per-segment so family names keep natural order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the scale-ordering comparator. The first version stripped a trailing scale keyword and re-based every variable, which misclassified real tokens whose name merely contains a size-like word: --sf-color-base (the colour "base" family) was reparsed as a size of --sf-color and scattered from its own --sf-color-base-* steps, and --sf-duration-none jumped to the front of the duration keywords. Rework Slashed_Category_Map::compare() to walk names segment by segment, classing each segment as scale size / numeric step / plain word, so sizes order by rank, numbers numerically and everything else naturally. A name that is a prefix of another (bare family token vs its steps) sorts first. This is transitive (a strict weak ordering, unlike a mixed rank/string compare) and keeps size-word-named families contiguous and in place. Restrict scale_order() to unambiguous size keywords (px, 4xs…7xl) and drop the ambiguous none/base/full/max so they stay beside their family. Add regression tests for the --sf-color-base and --sf-duration-none cases. Refs #232 --- .../includes/class-category-map.php | 184 +++++++++--------- tests-php/CategoryMapTest.php | 69 ++++++- 2 files changed, 155 insertions(+), 98 deletions(-) diff --git a/SLASHED-for-WP/includes/class-category-map.php b/SLASHED-for-WP/includes/class-category-map.php index 24d4bece..85ba182a 100644 --- a/SLASHED-for-WP/includes/class-category-map.php +++ b/SLASHED-for-WP/includes/class-category-map.php @@ -181,133 +181,133 @@ public static function label_for( $first_segment ) { } /** - * Semantic ordering rank for a token's trailing scale keyword. + * Semantic ordering rank for a t-shirt scale keyword. * - * Design tokens use a t-shirt scale (2xs → 7xl) plus a handful of edge - * keywords (none, px, base, full, max). A plain alphabetical / natural - * sort renders these in the wrong visual order — e.g. Spacing comes out as - * 2xl, 2xs, 3xl, l, m, s, xl, xs — because "2xl" sorts before "2xs" and the - * single letters land wherever the alphabet puts them. This map assigns - * each keyword a rank so a comparator can restore the intended small→large - * progression (2xs, xs, s, m, l, xl, 2xl, 3xl …). + * Design tokens size their variants on a t-shirt scale (4xs → 7xl, plus + * px). A plain alphabetical / natural sort renders these in the wrong + * visual order — e.g. Spacing comes out as 2xl, 2xs, 3xl, l, m, s, xl, xs — + * because "2xl" sorts before "2xs" and the single letters land wherever the + * alphabet puts them. This map assigns each size a rank so {@see compare()} + * can restore the intended small→large progression (2xs, xs, s, m, l, xl, + * 2xl, 3xl …). * - * Lower rank sorts earlier. Keywords absent from this map are treated as - * non-scale tokens by {@see compare()}. + * Only unambiguous *size* keywords live here. Words that a family or value + * can legitimately be named after — e.g. "base" (--sf-color-base) or "none" + * (--sf-duration-none) — are deliberately excluded so they keep their + * natural position beside the rest of their family rather than being + * misread as a size. Lower rank sorts earlier. * * @return array */ public static function scale_order() { return array( - 'none' => 0, - 'px' => 1, - '4xs' => 10, - '3xs' => 11, - '2xs' => 12, - 'xs' => 13, - 'sm' => 14, - 's' => 15, - 'base' => 16, - 'md' => 17, - 'm' => 18, - 'lg' => 19, - 'l' => 20, - 'xl' => 21, - '2xl' => 22, - '3xl' => 23, - '4xl' => 24, - '5xl' => 25, - '6xl' => 26, - '7xl' => 27, - 'full' => 40, - 'max' => 41, + 'px' => 1, + '4xs' => 10, + '3xs' => 11, + '2xs' => 12, + 'xs' => 13, + 'sm' => 14, + 's' => 15, + 'md' => 16, + 'm' => 17, + 'lg' => 18, + 'l' => 19, + 'xl' => 20, + '2xl' => 21, + '3xl' => 22, + '4xl' => 23, + '5xl' => 24, + '6xl' => 25, + '7xl' => 26, ); } /** * Compare two --sf-* variable names for semantic (scale-aware) ordering. * - * Names are first grouped by their "base" (the name with any trailing - * scale keyword or numeric step removed), then, within a base, ordered by - * scale rank (see {@see scale_order()}) or numeric step. This keeps a - * family such as Spacing in small→large order (--sf-space-2xs, -xs, -s, -m, - * -l, -xl, -2xl …) and colour steps in numeric order (--sf-color-primary-50, - * -100, …, -950) instead of the lexicographic jumble a plain sort() - * produces. Non-scale tokens keep their natural, case-insensitive order. + * Names are compared segment by segment (splitting on "-"). At the first + * segment that differs, each side is classed as a scale size, a numeric + * step, or a plain word, and ordered so that sizes come first (by scale + * rank), then numeric steps (numerically), then plain words (natural, + * case-insensitive). When one name is a prefix of the other, the shorter + * one sorts first — this keeps a bare family token (--sf-color-base) right + * before its own steps (--sf-color-base-50 …). * - * Suitable as the callback for usort(). + * The result: scale families read small→large (--sf-space-2xs, -xs, -s, -m, + * -l, -xl, -2xl …) and colour steps stay numeric (--sf-color-primary-50, + * -100, …, -950), while every other token — including families whose name + * merely contains a size-like word — keeps the natural, contiguous order it + * had before. Comparing per-segment (rather than stripping a trailing + * "suffix") is what guarantees a consistent, transitive ordering suitable + * for usort(). * * @param string $a First variable name (including leading "--"). * @param string $b Second variable name. * @return int Negative, zero, or positive per the usort() contract. */ public static function compare( $a, $b ) { - $pa = self::split_scale( (string) $a ); - $pb = self::split_scale( (string) $b ); + $sa = explode( '-', (string) $a ); + $sb = explode( '-', (string) $b ); + $len = min( count( $sa ), count( $sb ) ); - // Different families/bases: fall back to natural, case-insensitive - // order so category members stay grouped the way they always were. - $base_cmp = strnatcasecmp( $pa['base'], $pb['base'] ); - if ( 0 !== $base_cmp ) { - return $base_cmp; + for ( $i = 0; $i < $len; $i++ ) { + $cmp = self::compare_segment( $sa[ $i ], $sb[ $i ] ); + if ( 0 !== $cmp ) { + return $cmp; + } } - // Same base: order by semantic rank (scale keyword or numeric step). - if ( $pa['rank'] !== $pb['rank'] ) { - return ( $pa['rank'] < $pb['rank'] ) ? -1 : 1; - } - - // Identical rank (e.g. two unrelated non-scale tokens): stable, - // natural, case-insensitive tie-break on the full names. - return strnatcasecmp( (string) $a, (string) $b ); + // Every shared segment matched: the shorter name is a prefix of the + // longer one (bare family token vs its numbered steps) and sorts first. + return count( $sa ) <=> count( $sb ); } /** - * Split a variable name into its scale "base" and a numeric ordering rank. + * Compare a single "-"-delimited segment of two variable names. * - * The trailing "-{segment}" is inspected: a known scale keyword yields its - * {@see scale_order()} rank; a purely numeric segment yields that integer - * offset past the keyword band (so 50 < 100 < 950 and a bare base token - * still sorts before its numbered steps); anything else is treated as a - * non-scale token whose base is the full name and whose rank is 0. + * Segments are classed as scale size (0), numeric step (1) or plain word + * (2); a lower class sorts first so a family's sized/numbered members stay + * ahead of its alpha variants. Within the same class, sizes and numbers + * compare by value and plain words by natural, case-insensitive order. * - * @param string $name Full variable name. - * @return array{base: string, rank: int} + * @param string $a First segment. + * @param string $b Second segment. + * @return int Negative, zero, or positive. */ - private static function split_scale( $name ) { - $dash = strrpos( $name, '-' ); - if ( false === $dash || strlen( $name ) - 1 === $dash ) { - return array( - 'base' => $name, - 'rank' => 0, - ); + private static function compare_segment( $a, $b ) { + $ka = self::segment_key( $a ); + $kb = self::segment_key( $b ); + + if ( $ka[0] !== $kb[0] ) { + return $ka[0] <=> $kb[0]; } - $suffix = substr( $name, $dash + 1 ); - $base = substr( $name, 0, $dash ); + // Plain words: natural, case-insensitive. Sizes / numbers: by value. + if ( 2 === $ka[0] ) { + return strnatcasecmp( (string) $ka[1], (string) $kb[1] ); + } + return $ka[1] <=> $kb[1]; + } + /** + * Classify a segment for ordering: [ class, value ]. + * + * Class 0 = scale size (value is its {@see scale_order()} rank), class 1 = + * numeric step (value is the integer), class 2 = plain word (value is the + * original string, compared naturally). + * + * @param string $segment A single "-"-delimited segment. + * @return array{0: int, 1: int|string} + */ + private static function segment_key( $segment ) { $scale = self::scale_order(); - $key = strtolower( $suffix ); + $key = strtolower( $segment ); if ( isset( $scale[ $key ] ) ) { - return array( - 'base' => $base, - 'rank' => $scale[ $key ], - ); + return array( 0, $scale[ $key ] ); } - - // Numeric step (colour scales: -50, -100, … -950). Offset past the - // keyword-rank band so a bare base token still sorts before its steps. - if ( '' !== $suffix && ctype_digit( $suffix ) ) { - return array( - 'base' => $base, - 'rank' => 100 + (int) $suffix, - ); + if ( '' !== $segment && ctype_digit( $segment ) ) { + return array( 1, (int) $segment ); } - - // Non-scale token: keep the full name as its own base so unrelated - // tokens simply fall back to natural ordering against each other. - return array( - 'base' => $name, - 'rank' => 0, - ); + return array( 2, $segment ); } } diff --git a/tests-php/CategoryMapTest.php b/tests-php/CategoryMapTest.php index ef2805f4..1962b72f 100644 --- a/tests-php/CategoryMapTest.php +++ b/tests-php/CategoryMapTest.php @@ -104,10 +104,11 @@ public function test_compare_orders_tshirt_scale_small_to_large() { } /** - * Edge keywords (none, px, base) slot into the scale, and non-scale config - * tokens (ratio, scale) sort after the scale members of the family. + * The size keyword px sorts into the scale (ahead of 2xs), while non-size + * words — including the ambiguous "none" and config tokens (ratio, scale) — + * keep their natural order after the sized members of the family. */ - public function test_compare_places_edge_keywords_and_non_scale_tokens() { + public function test_compare_places_sizes_ahead_of_non_scale_words() { $input = array( '--sf-space-scale', '--sf-space-m', @@ -120,12 +121,12 @@ public function test_compare_places_edge_keywords_and_non_scale_tokens() { $this->assertSame( array( - // none → px → xs → m are the scale members (base "--sf-space"). - '--sf-space-none', + // px → xs → m are the sized members (px is 1px, the smallest). '--sf-space-px', '--sf-space-xs', '--sf-space-m', - // Non-scale config tokens keep natural order, after the scale. + // Non-size words keep natural order, after the sized members. + '--sf-space-none', '--sf-space-ratio', '--sf-space-scale', ), @@ -133,6 +134,62 @@ public function test_compare_places_edge_keywords_and_non_scale_tokens() { ); } + /** + * Regression guard for issue #232 review feedback: a family whose name + * merely contains a size-like word ("base") must stay contiguous with its + * own steps and keep its natural position — it must NOT be reparsed as a + * size and scattered. --sf-color-base is the colour "base" family, not a + * size of --sf-color. + */ + public function test_compare_keeps_family_named_after_a_size_word_intact() { + $input = array( + '--sf-color-base-100', + '--sf-color-primary', + '--sf-color-base', + '--sf-color-base-50', + '--sf-color-action', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + // Families keep natural alphabetical order: action < base < primary… + '--sf-color-action', + // …and the "base" family stays together, bare token before steps. + '--sf-color-base', + '--sf-color-base-50', + '--sf-color-base-100', + '--sf-color-primary', + ), + $input + ); + } + + /** + * Regression guard for issue #232 review feedback: "none" is a value, not a + * size, so --sf-duration-none must keep its natural position among the + * other duration keywords rather than jumping to the front. + */ + public function test_compare_does_not_treat_none_as_a_size() { + $input = array( + '--sf-duration-normal', + '--sf-duration-none', + '--sf-duration-fast', + '--sf-duration-instant', + ); + usort( $input, array( 'Slashed_Category_Map', 'compare' ) ); + + $this->assertSame( + array( + '--sf-duration-fast', + '--sf-duration-instant', + '--sf-duration-none', + '--sf-duration-normal', + ), + $input + ); + } + /** * Numeric colour steps must order numerically (50 < 100 < 950), with the * bare family token ahead of its numbered steps.