Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions SLASHED-for-WP/includes/class-category-map.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, int>
*/
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 );
}
}
9 changes: 6 additions & 3 deletions SLASHED-for-WP/includes/class-inventory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>
*/
Expand All @@ -422,15 +425,15 @@ 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 ];
}
}

// 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;
}
}
Expand Down
181 changes: 181 additions & 0 deletions tests-php/CategoryMapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
}
}
}