diff --git a/SLASHED-for-WP/includes/class-category-map.php b/SLASHED-for-WP/includes/class-category-map.php index 77082fc..85ba182 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 t-shirt scale keyword. + * + * 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 …). + * + * 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( + '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 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 …). + * + * 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 ) { + $sa = explode( '-', (string) $a ); + $sb = explode( '-', (string) $b ); + $len = min( count( $sa ), count( $sb ) ); + + for ( $i = 0; $i < $len; $i++ ) { + $cmp = self::compare_segment( $sa[ $i ], $sb[ $i ] ); + if ( 0 !== $cmp ) { + return $cmp; + } + } + + // 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 ); + } + + /** + * Compare a single "-"-delimited segment of two variable names. + * + * 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 $a First segment. + * @param string $b Second segment. + * @return int Negative, zero, or positive. + */ + 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]; + } + + // 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( $segment ); + if ( isset( $scale[ $key ] ) ) { + return array( 0, $scale[ $key ] ); + } + if ( '' !== $segment && ctype_digit( $segment ) ) { + return array( 1, (int) $segment ); + } + return array( 2, $segment ); + } } diff --git a/SLASHED-for-WP/includes/class-inventory.php b/SLASHED-for-WP/includes/class-inventory.php index 44249f4..3c11db0 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 7ab9cc4..1962b72 100644 --- a/tests-php/CategoryMapTest.php +++ b/tests-php/CategoryMapTest.php @@ -69,4 +69,185 @@ 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 + ); + } + + /** + * 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_sizes_ahead_of_non_scale_words() { + $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( + // px → xs → m are the sized members (px is 1px, the smallest). + '--sf-space-px', + '--sf-space-xs', + '--sf-space-m', + // Non-size words keep natural order, after the sized members. + '--sf-space-none', + '--sf-space-ratio', + '--sf-space-scale', + ), + $input + ); + } + + /** + * 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. + */ + 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 ]; + } + } }