@axiapps/axi-design 1.42.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,13 +75,69 @@ a.axi-panel--tile:hover,
75
75
  button.axi-panel--tile:hover,
76
76
  .axi-panel--tile[role='button']:hover { box-shadow: var(--axi-shadow-control-hover); }
77
77
 
78
+ /* The one of these you picked. A grid of panels, tiles or cards where one is
79
+ chosen - a theme picker, a fight picker, a leader you have opened - and the
80
+ surface has to say which.
81
+
82
+ Not the accent fill a rail item takes. A rail item is a word, and a word can
83
+ be printed on the accent; a panel carries content, and an accent fill drowns
84
+ it. The answer is the third weight .axi-rail__nav--quiet already found for
85
+ the same reason: the surface rises and the accent arrives on the edge. A
86
+ whole edge here rather than the leading one, because a grid is read in two
87
+ directions and has no leading side to light.
88
+
89
+ Keyed off what the element already says, the way .axi-panel--tile keys its
90
+ press off being an <a> or a <button> rather than off a second class. The
91
+ language has one spelling per selection semantics, and this rule takes all
92
+ of them rather than inventing a fourth: `aria-current` for a place you
93
+ navigated to (a rail item, a tab, a table row), `aria-pressed` for a toggle
94
+ (a pill), `aria-selected` for an option in a listbox (a picker option). A
95
+ consumer that marks a card by appending a border utility has told assistive
96
+ tech nothing at all, and that is the defect this replaces rather than the
97
+ missing fill - ten sites in one consumer, six different bases, three
98
+ invented extra marks, and not one of them announced.
99
+
100
+ The fourth arm is a <label> wrapping its own radio, which is the correct
101
+ markup for a picker made of panels: the input carries the state, so the
102
+ label has no attribute to set and copying the state onto one would be a
103
+ second source of truth that can disagree. This is the language's only
104
+ :has(), and the child combinator is the whole point - `> input:checked`
105
+ means this panel IS the control, where a descendant match would light a
106
+ panel up for any checkbox buried in its content.
107
+
108
+ Both spellings on one rule, per "a style only reachable through a layer will
109
+ be re-invented": .axi-card is a shell and .axi-panel a primitive, and a
110
+ consumer marking one of them must not have to find out which file its
111
+ selected state was written in. The fill is the `background` shorthand and
112
+ not the -paint companion, because --axi-surface-raised is a surface token
113
+ and a theme is allowed to make a surface a gradient. */
114
+ :is(.axi-card, .axi-panel):is(
115
+ [aria-current],
116
+ [aria-pressed='true'],
117
+ [aria-selected='true'],
118
+ :has(> input:checked)
119
+ ) {
120
+ background: var(--axi-surface-raised);
121
+ border-color: var(--axi-accent);
122
+ }
123
+
78
124
  /* ---------- well ---------- */
79
125
  /* The panel's inverse: a field sunk into the surface around it rather than
80
126
  raised off it. No block, because a recess casts nothing, and the rule rather
81
127
  than the ink for its edge - see --axi-well-fill in src/tokens.css for why
82
128
  both of those follow from the same fact. Reach for this wherever a second
83
129
  panel inside a panel was the instinct: a picker list beside a table, a column
84
- of cards standing on the page, a trough a bar fills. */
130
+ of cards standing on the page, a trough a bar fills.
131
+
132
+ .axi-split__nav is the first of those three named as a part rather than left
133
+ to the consumer to remember, and it is in THIS rule rather than restating the
134
+ fill next to the split pane. Two rules that agree today are two rules that
135
+ disagree after the next edit - see "A style only reachable through a layer
136
+ will be re-invented" in docs/RULES.md, which is the same argument arriving
137
+ from the other direction. The split pane's slot cannot be a well by
138
+ convention: a consumer who forgets the second class gets a picker with no
139
+ recess, and the language has no way to notice. */
140
+ .axi-split__nav,
85
141
  .axi-well {
86
142
  background: var(--axi-well-fill);
87
143
  border: var(--axi-border-control) solid var(--axi-well-line);
@@ -180,6 +236,43 @@ button.axi-panel--tile:hover,
180
236
  icon-only button has no label to pad around, so the 20px sides meant for
181
237
  one produced a wide rectangle around a single glyph - visible in this
182
238
  language's own gallery, in the example titled "Alone in a button". */
239
+ /* Icon over label rather than beside it. Not a style choice - it is the only
240
+ thing that makes a bar of equal actions fit a phone. Measured on the
241
+ consumer that needed it: four icon+label actions side by side want 387px of
242
+ the 337px available at 393px wide, and every label is one unbreakable word,
243
+ so min-content equals max-content and flex-shrink has nothing to give. The
244
+ last action simply runs off the screen. Stacked, the same four come to
245
+ ~291px.
246
+
247
+ min-width: 0 because that failure has a second half. A flex item's default
248
+ min-width is its content, so a button in a row refuses to shrink below its
249
+ own label even when the row is told to divide the space - which is the same
250
+ trap .axi-tabs__tab documents for its label and .axi-split__body for its
251
+ track.
252
+
253
+ The label is addressed as an element because a stacked button has two
254
+ children and only one of them is words; naming a part would make every
255
+ consumer add a class to say what the span already says. :where() so it
256
+ spends no specificity - the ink layer and any modifier still out-rank it,
257
+ which is the contract src/utilities.css is owed. */
258
+ .axi-btn--stack {
259
+ flex-direction: column;
260
+ gap: 5px;
261
+ min-width: 0;
262
+ }
263
+ .axi-btn--stack > :where(span) {
264
+ /* Both, and the pair is the point: max-width caps it against the button,
265
+ min-width: 0 is what lets it shrink at all. A flex item's default
266
+ min-width is its content, so max-width alone leaves the ellipsis
267
+ unreachable - the label overflows the cap instead of truncating inside
268
+ it. tests/tokens.test.mjs pins the pair for every truncating rule in the
269
+ language, and it caught this one. */
270
+ max-width: 100%;
271
+ min-width: 0;
272
+ overflow: hidden;
273
+ text-overflow: ellipsis;
274
+ white-space: nowrap;
275
+ }
183
276
  .axi-btn--sm { --axi-btn-pad: 8px 13px; --axi-btn-size: 12px; }
184
277
  .axi-btn--xs { --axi-btn-pad: 5px 9px; --axi-btn-size: 11px; }
185
278
  .axi-btn--icon { --axi-btn-pad: 7px; }
@@ -478,7 +571,7 @@ button.axi-panel--tile:hover,
478
571
  says `axi-link axi-ink-dim` must stay dim under the cursor, and the plain
479
572
  `:hover` form is what made the danger button read white under one. */
480
573
  .axi-link,
481
- .axi-prose a {
574
+ .axi-prose :where(a) {
482
575
  padding: 0;
483
576
  background: none;
484
577
  border: 0;
@@ -491,7 +584,7 @@ button.axi-panel--tile:hover,
491
584
  cursor: pointer;
492
585
  }
493
586
  .axi-link:where(:hover),
494
- .axi-prose a:where(:hover) { color: var(--axi-text); }
587
+ .axi-prose :where(a:hover) { color: var(--axi-text); }
495
588
 
496
589
  /* ---------- quiet action ---------- */
497
590
  /* A control that is only its label. Not a button - it draws no box, takes no
@@ -617,8 +710,8 @@ button.axi-panel--tile:hover,
617
710
  disclosure that drops back flat the moment the pointer moves into the list
618
711
  it opened severs the two: the lift is what says this box and that popover
619
712
  are one control. */
620
- .axi-select:hover,
621
- .axi-picker__btn:hover,
713
+ .axi-select:where(:hover),
714
+ .axi-picker__btn:where(:hover),
622
715
  .axi-picker__btn[aria-expanded='true'] {
623
716
  color: var(--axi-text);
624
717
  box-shadow: var(--axi-shadow-control);
@@ -645,7 +738,7 @@ button.axi-panel--tile:hover,
645
738
  backdrop-filter: var(--axi-surface-filter);
646
739
  box-shadow: var(--axi-shadow-panel);
647
740
  }
648
- .axi-select option {
741
+ .axi-select :where(option) {
649
742
  display: flex; align-items: center; gap: 9px;
650
743
  padding: 8px 9px;
651
744
  border-radius: var(--axi-radius-sm);
@@ -660,7 +753,7 @@ button.axi-panel--tile:hover,
660
753
  level's fill - and this popover is filled with --axi-surface-raised, which
661
754
  a theme may take translucent, so an opaque row here is the black patch
662
755
  --axi-well-fill exists to prevent. Same for the picker's rows below. */
663
- .axi-select option:hover, .axi-select option:focus {
756
+ .axi-select :where(option:hover, option:focus) {
664
757
  background: var(--axi-well-fill); color: var(--axi-text);
665
758
  }
666
759
  /* The page-wide focus ring sits 2px outside its element; inside a picker
@@ -759,7 +852,7 @@ button.axi-panel--tile:hover,
759
852
  color: transparent;
760
853
  font-weight: 900;
761
854
  }
762
- .axi-picker__opt:hover, .axi-picker__opt:focus {
855
+ .axi-picker__opt:where(:hover, :focus) {
763
856
  background: var(--axi-well-fill); color: var(--axi-text);
764
857
  }
765
858
  /* The page-wide focus ring sits 2px outside its element; inside a popover
package/src/shells.css CHANGED
@@ -3,14 +3,23 @@
3
3
  redefining them, so a change to .axi-panel moves the notice, the toolbar,
4
4
  the popover and the card together. */
5
5
 
6
- /* The small uppercase heading that labels a panel's contents. */
6
+ /* The small uppercase heading that labels a panel's contents.
7
+
8
+ The gap under it is a knob because the eyebrow heads more than one size of
9
+ thing. 14px is the figure for a panel at its resting padding; a tile is one
10
+ form step down and its padding is one density step down with it, so the
11
+ eyebrow inside one reads the tighter figure in the rule below, the same way
12
+ .axi-panel--tile reads --axi-panel-pad at a tighter fallback of its own. An
13
+ eyebrow sitting in a row - a sheet's head, a toolbar - wants no gap at all,
14
+ and says so with the knob rather than with an inline margin. */
7
15
  .axi-eyebrow {
8
- margin: 0 0 14px;
16
+ margin: 0 0 var(--axi-eyebrow-gap, 14px);
9
17
  font: var(--axi-t-eyebrow);
10
18
  letter-spacing: var(--axi-ls-eyebrow);
11
19
  text-transform: uppercase;
12
20
  color: var(--axi-text-faint);
13
21
  }
22
+ .axi-panel--tile .axi-eyebrow { margin-bottom: var(--axi-eyebrow-gap, 6px); }
14
23
 
15
24
  /* ---------- masthead ---------- */
16
25
  /* Opaque, because content scrolls under it. Which is also why it paints the
@@ -95,9 +104,35 @@
95
104
  it shrinks an item - so one tab too many sent the last item in source order
96
105
  to a second row instead of taking width off the widest one. The strip is the
97
106
  item that can give: it scrolls, and the row stays a row. */
98
- .axi-mast .axi-tabs { margin-left: auto; flex-shrink: 1; min-width: 0; overflow-x: auto; }
99
- .axi-tabs a,
100
- .axi-tabs button {
107
+ .axi-mast .axi-tabs { margin-left: auto; }
108
+ /* A strip with more tabs than room scrolls rather than losing them.
109
+
110
+ .axi-tabs is `display: flex` with no wrap, so a strip too wide for its
111
+ container overflows - and outside a scroll container the tabs past the edge
112
+ are simply unreachable. The language already believed this twice and could
113
+ say it in neither place a consumer could use: once here, behind .axi-mast,
114
+ and once at the 640px breakpoint below, where EVERY strip scrolls. A
115
+ standalone strip above that breakpoint with one tab too many had nowhere to
116
+ look, and the consumer that hit it wrote `overflow-x: auto` on its own
117
+ wrapper - the same re-invention docs/RULES.md describes, arriving a fourth
118
+ time.
119
+
120
+ A modifier rather than the base rule, and this is the part worth arguing.
121
+ `overflow-x: auto` computes `overflow-y` from `visible` to `auto`, so a
122
+ strip that scrolls is also a strip that clips vertically - a focus ring, a
123
+ badge hanging off a tab, a popover anchored to one. Every strip paying that
124
+ to fix the strips that are too long is the wrong trade; the strips that are
125
+ too long say so.
126
+
127
+ The bar is NOT hidden here, and the quiet-scroll rule in src/utilities.css
128
+ deliberately does not name this. That rule is about a bar drawn down the
129
+ side of a narrow strip, where the object's own edge is already a vertical
130
+ line and a count elsewhere already says how many rows there are. A tab strip
131
+ that scrolls horizontally has no second signal: the bar is the only thing
132
+ saying there are more tabs. Same reason .axi-table__scroll keeps its. */
133
+ .axi-tabs--scroll,
134
+ .axi-mast .axi-tabs { flex-shrink: 1; min-width: 0; overflow-x: auto; }
135
+ .axi-tabs :where(a, button) {
101
136
  padding: 8px 16px;
102
137
  font: 800 13.5px/1 var(--axi-sans);
103
138
  color: var(--axi-text-dim); text-decoration: none;
@@ -106,8 +141,7 @@
106
141
  border: var(--axi-border-control) solid transparent;
107
142
  border-radius: var(--axi-radius-sm);
108
143
  }
109
- .axi-tabs a:hover,
110
- .axi-tabs button:hover { color: var(--axi-text); border-color: var(--axi-rule); }
144
+ .axi-tabs :where(a:hover, button:hover) { color: var(--axi-text); border-color: var(--axi-rule); }
111
145
  /* The current tab is filled and blocked - the same treatment a pressed pill
112
146
  gets, because it is the same idea: this one is on. */
113
147
  .axi-tabs a[aria-current="page"],
@@ -139,13 +173,13 @@
139
173
  text-overflow: ellipsis;
140
174
  white-space: nowrap;
141
175
  }
142
- .axi-tabs .axi-tabs__close {
176
+ .axi-tabs :where(.axi-tabs__close) {
143
177
  padding: 0 6px;
144
178
  font: var(--axi-t-micro);
145
179
  color: var(--axi-text-faint);
146
180
  border-color: transparent;
147
181
  }
148
- .axi-tabs .axi-tabs__close:hover { color: var(--axi-text); border-color: transparent; }
182
+ .axi-tabs :where(.axi-tabs__close:hover) { color: var(--axi-text); border-color: transparent; }
149
183
 
150
184
  /* ---------- rail ---------- */
151
185
  /* Vertical navigation, for a set too large for a tab strip to hold. Same
@@ -219,7 +253,7 @@
219
253
  border-radius: var(--axi-radius-sm);
220
254
  cursor: pointer;
221
255
  }
222
- .axi-rail__item:hover { color: var(--axi-text); background: var(--axi-surface-raised); }
256
+ .axi-rail__item:where(:hover) { color: var(--axi-text); background: var(--axi-surface-raised); }
223
257
  /* The same treatment a pressed pill and a current tab get. Only ever one of
224
258
  these on screen. Any aria-current value, because the right one depends on
225
259
  what the rail navigates - `page` for a view, `location` for a place within
@@ -282,7 +316,7 @@
282
316
  border-radius: var(--axi-radius-sm);
283
317
  cursor: pointer;
284
318
  }
285
- .axi-rail__subitem:hover { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
319
+ .axi-rail__subitem:where(:hover) { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
286
320
  .axi-rail__subitem[aria-current] { color: var(--axi-text); }
287
321
  /* An annotation on an item, not a measurement of it, so rule 5 draws it
288
322
  outlined rather than filled. */
@@ -430,13 +464,13 @@
430
464
  box-shadow: var(--axi-shadow-panel);
431
465
  }
432
466
  .axi-menu__pop[hidden] { display: none; }
433
- .axi-menu__pop label {
467
+ .axi-menu__pop :where(label) {
434
468
  display: flex; gap: 9px; align-items: flex-start;
435
469
  padding: 7px 8px; border-radius: var(--axi-radius-sm);
436
470
  font-size: 13px; font-weight: 600; color: var(--axi-text-dim); cursor: pointer;
437
471
  }
438
472
  /* A hovered row sinks - the same fill the select's and the picker's rows use. */
439
- .axi-menu__pop label:hover { background: var(--axi-well-fill); color: var(--axi-text); }
473
+ .axi-menu__pop :where(label:hover) { background: var(--axi-well-fill); color: var(--axi-text); }
440
474
  .axi-menu__pop input { margin: 3px 0 0; }
441
475
 
442
476
  /* ---------- command palette ---------- */
@@ -497,14 +531,16 @@
497
531
  }
498
532
  /* min-height: 0 because the panel is a flex column and a list that cannot
499
533
  shrink below its content pushes the panel past its own max-height. The
500
- scrollbar is hidden: at twelve rows behind a fixed cap the bar is a channel
501
- carved down the side of a small panel to report a fact the row count in the
502
- bar has already given. */
534
+ scrollbar is hidden, but not here: that decision is the language's and not
535
+ this component's, and it now lives once, in src/utilities.css under "the
536
+ quiet scroll", with this selector in the same rule as every other strip that
537
+ makes it. The argument that produced it was written here first - at twelve
538
+ rows behind a fixed cap the bar is a channel carved down the side of a small
539
+ panel to report a fact the row count in the bar has already given - and it is
540
+ preserved in full at the rule. */
503
541
  .axi-palette__list {
504
542
  flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0;
505
- scrollbar-width: none;
506
543
  }
507
- .axi-palette__list::-webkit-scrollbar { width: 0; height: 0; }
508
544
  /* One .axi-palette__section per group of rows, each opening with a
509
545
  .axi-palette__group heading. The section is a named part rather than left to
510
546
  the consumer's wrapper so the rule between groups can be stated as what it
@@ -526,7 +562,7 @@
526
562
  color: var(--axi-text-dim);
527
563
  font: var(--axi-t-small); text-align: left; cursor: pointer;
528
564
  }
529
- .axi-palette__row:hover { background: var(--axi-surface-raised); color: var(--axi-text); }
565
+ .axi-palette__row:where(:hover) { background: var(--axi-surface-raised); color: var(--axi-text); }
530
566
  .axi-palette__row[data-active] { background: var(--axi-accent); color: var(--axi-accent-ink); }
531
567
  /* The row's own ink is set above, but a row carries a glyph and a second line
532
568
  of text that were coloured on their own; on the filled row they all become
@@ -558,7 +594,7 @@
558
594
  cursor: pointer;
559
595
  transition: transform .1s, box-shadow .1s, color .1s;
560
596
  }
561
- .axi-palette__trigger:hover {
597
+ .axi-palette__trigger:where(:hover) {
562
598
  color: var(--axi-text);
563
599
  box-shadow: var(--axi-shadow-control);
564
600
  transform: translate(-2px, -2px);
@@ -705,10 +741,47 @@
705
741
  color: var(--axi-text-faint);
706
742
  }
707
743
  .axi-card__go { margin-left: auto; color: var(--axi-accent); font-weight: 900; text-transform: uppercase; letter-spacing: .05em; }
708
- .axi-card:hover .axi-card__go { color: var(--axi-text); }
744
+ .axi-card:where(:hover) :where(.axi-card__go) { color: var(--axi-text); }
709
745
 
710
746
  /* ---------- drawer ---------- */
747
+ /* A scrim's layer is not a property of the scrim. It is "directly below the
748
+ thing I dismiss", and this language has two things that get dismissed at two
749
+ different rungs, so the scrim has two.
750
+
751
+ The default is the drawer-and-modal rung at 50, which is where
752
+ --axi-scrim's own value was tuned (its token comment reads "behind a
753
+ drawer"). `.axi-scrim--sheet` is the same scrim one rung below the sheet at
754
+ 45.
755
+
756
+ Without that modifier a sheet cannot be scrimmed at all, and the failure is
757
+ not cosmetic - it is a dead interface. Both elements are `position: fixed` in
758
+ the same stacking context with no isolation between them, so at 50 over 45
759
+ the scrim covers the sheet completely: measured with
760
+ `document.elementFromPoint` at the centre of an open sheet, the element
761
+ returned is the scrim. Every click lands on the dismiss handler instead of on
762
+ the content, so the sheet opens and then does nothing.
763
+
764
+ The sheet still wants a scrim even though it is opaque and covers the view,
765
+ for two reasons the sheet's own note explains. --axi-sheet-top pushes it down
766
+ under an app's titlebar, so a strip of live page stays visible above it and
767
+ wants darkening; and the scrim is the click-to-dismiss target for that strip,
768
+ which is a function rather than an appearance.
769
+
770
+ A sheet that opens a modal or a drawer is unaffected: those land at 50 and
771
+ 51, above both the sheet and its scrim, which is the ordering the sheet's own
772
+ comment argues for. That is why this is a second rung and not a change to the
773
+ first - moving the scrim below 45 outright would put it under the drawer it
774
+ was measured for.
775
+
776
+ No motion here, deliberately. This language has no transition vocabulary -
777
+ no duration or easing tokens - and rule 11 is a restriction on work
778
+ indicators rather than a system for animating chrome. A consumer that fades
779
+ a scrim in owns that animation, and owns keeping its keyframes off the fill:
780
+ an enter/exit class that also sets a background is a colour literal standing
781
+ on top of --axi-scrim, which is how the one consumer here had quietly
782
+ replaced the token with rgba(10, 14, 18, .85). */
711
783
  .axi-scrim { position: fixed; inset: 0; z-index: 50; background: var(--axi-scrim); backdrop-filter: var(--axi-surface-filter); border: 0; }
784
+ .axi-scrim--sheet { z-index: 44; }
712
785
  .axi-drawer {
713
786
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 51;
714
787
  width: min(var(--axi-drawer-width, 560px), 100vw);
@@ -741,7 +814,7 @@
741
814
  font-size: 15px; font-weight: 900; cursor: pointer;
742
815
  transition: transform .1s, box-shadow .1s;
743
816
  }
744
- .axi-drawer__close:hover {
817
+ .axi-drawer__close:where(:hover) {
745
818
  background: var(--axi-accent); color: var(--axi-accent-ink);
746
819
  box-shadow: var(--axi-shadow-control);
747
820
  transform: translate(-2px, -2px);
@@ -883,7 +956,7 @@
883
956
  -webkit-app-region: drag;
884
957
  }
885
958
  .axi-titlebar__btns { margin-left: auto; display: flex; height: 100%; -webkit-app-region: no-drag; }
886
- .axi-titlebar__btns button {
959
+ .axi-titlebar__btns :where(button) {
887
960
  width: 44px;
888
961
  border: 0;
889
962
  background: transparent;
@@ -894,10 +967,10 @@
894
967
  /* No lift here. A window button is chrome, not content: it has nothing to
895
968
  lift off, and 44px of movement in the corner of every screen is exactly
896
969
  the scattered motion rule 4 rations. It answers on the neutral ramp. */
897
- .axi-titlebar__btns button:hover { background: var(--axi-surface); color: var(--axi-text); }
970
+ .axi-titlebar__btns :where(button:hover) { background: var(--axi-surface); color: var(--axi-text); }
898
971
  /* Close is the one destructive control in the chrome, and the one place a
899
972
  status ink belongs in a titlebar. */
900
- .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
973
+ .axi-titlebar__btns :where(button:last-child:hover) { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
901
974
  /* The scrolling region under the chrome. */
902
975
  .axi-window__body { flex: 1; overflow-y: auto; }
903
976
 
@@ -916,8 +989,8 @@
916
989
  flex-wrap: wrap;
917
990
  font: var(--axi-t-small);
918
991
  }
919
- .axi-crumbs a { color: var(--axi-text-dim); text-decoration: none; }
920
- .axi-crumbs a:hover { color: var(--axi-accent); }
992
+ .axi-crumbs :where(a) { color: var(--axi-text-dim); text-decoration: none; }
993
+ .axi-crumbs :where(a:hover) { color: var(--axi-accent); }
921
994
  /* 7px, matching the prose bullet (src/prose.css) exactly, with the same
922
995
  --axi-border-hairline outline: rule 7 calls the motif "a 45deg-rotated
923
996
  outlined square", and a bare fill with no border does not match it. The
@@ -951,7 +1024,7 @@
951
1024
  border: var(--axi-border-control) solid var(--axi-ink-line);
952
1025
  border-radius: var(--axi-radius-sm);
953
1026
  }
954
- .axi-pages__n:hover { color: var(--axi-text); }
1027
+ .axi-pages__n:where(:hover) { color: var(--axi-text); }
955
1028
  .axi-pages__n[aria-current="page"] {
956
1029
  background: var(--axi-accent);
957
1030
  color: var(--axi-accent-ink);
@@ -964,7 +1037,12 @@
964
1037
  @media (max-width: 640px) {
965
1038
  .axi-mast__in { min-height: 0; padding: 10px 16px; gap: 12px; }
966
1039
  .axi-mast .axi-tabs { margin-left: 0; }
967
- .axi-tabs { width: 100%; overflow-x: auto; }
1040
+ /* Every strip scrolls on a phone, whether or not it asked. This cannot join
1041
+ the shared rule above - a media-query rule and an unconditional one are
1042
+ different rules by construction - so the values are pinned against it by
1043
+ test instead: the arm may declare nothing the modifier does not, and
1044
+ nothing at a different value. */
1045
+ .axi-tabs { width: 100%; flex-shrink: 1; min-width: 0; overflow-x: auto; }
968
1046
  /* The offset blocks eat horizontal room a phone does not have, so the panel
969
1047
  step drops to the control step here rather than to an invented number.
970
1048
  The card's hover has to come down with it - left alone it kept deepening
package/src/utilities.css CHANGED
@@ -1,6 +1,6 @@
1
1
  /* axi design language - utilities.
2
- Two families of single-purpose class and one state, and the file is last in
3
- the cascade
2
+ Two families of single-purpose class, one state and one refusal, and the file
3
+ is last in the cascade
4
4
  because that is the whole point of it: a utility has to be able to overrule
5
5
  the component it sits inside, and it is not allowed to buy that with
6
6
  !important or with a padded selector.
@@ -103,6 +103,7 @@
103
103
  .axi-chip--action:is(:disabled, [aria-disabled="true"]),
104
104
  .axi-drawer__close:is(:disabled, [aria-disabled="true"]),
105
105
  .axi-input:is(:disabled, [aria-disabled="true"]),
106
+ .axi-legend__key:where(button):is(:disabled, [aria-disabled="true"]),
106
107
  .axi-link:is(:disabled, [aria-disabled="true"]),
107
108
  .axi-palette__row:is(:disabled, [aria-disabled="true"]),
108
109
  .axi-palette__trigger:is(:disabled, [aria-disabled="true"]),
@@ -115,11 +116,65 @@
115
116
  .axi-select:is(:disabled, [aria-disabled="true"]),
116
117
  .axi-switch:is(:disabled, [aria-disabled="true"]),
117
118
  .axi-table__sort:is(:disabled, [aria-disabled="true"]),
118
- .axi-menu__pop label:is(:disabled, [aria-disabled="true"]),
119
- .axi-prose a:is(:disabled, [aria-disabled="true"]),
120
- .axi-tabs a:is(:disabled, [aria-disabled="true"]),
121
- .axi-tabs button:is(:disabled, [aria-disabled="true"]),
122
- .axi-titlebar__btns button:is(:disabled, [aria-disabled="true"]) {
119
+ .axi-menu__pop :where(label):is(:disabled, [aria-disabled="true"]),
120
+ .axi-prose :where(a):is(:disabled, [aria-disabled="true"]),
121
+ .axi-tabs :where(a, button):is(:disabled, [aria-disabled="true"]),
122
+ .axi-titlebar__btns :where(button):is(:disabled, [aria-disabled="true"]) {
123
123
  opacity: .5;
124
124
  cursor: not-allowed;
125
125
  }
126
+
127
+ /* ---------- the quiet scroll ---------- */
128
+ /* A scrollbar down the side of a narrow strip is a channel, not information.
129
+
130
+ The language had already decided this and said it in exactly one place, on
131
+ .axi-palette__list, with the argument written inline: at twelve rows behind
132
+ a fixed cap the bar reports a fact the row count in the bar has already
133
+ given. Said only there, it was the language's only word for the decision -
134
+ and the consumer that needed the same thing for its own strips could not
135
+ reach it, so it wrote the rule again. Five times, in its own stylesheet, for
136
+ two rails and a picker slot, twice with a `*` descendant arm because the
137
+ element that actually scrolls sits one level inside a component it does not
138
+ control. That is the third instance of the pattern in docs/RULES.md ("A style
139
+ only reachable through a layer will be re-invented"), and the remedy is the
140
+ same one: give the decision a name a consumer can spend, and put every
141
+ spelling of it in ONE rule.
142
+
143
+ So: one rule. The class is what a consumer says about its own scroller; the
144
+ four component parts beside it are the strips the language ships. Two rules
145
+ that agree today are two rules that disagree after the next edit.
146
+
147
+ This is a refusal rather than a utility - it says what NOT to draw - which is
148
+ why it is here rather than beside each component even though nothing needs
149
+ the last-in-cascade position to win. One fact about parts living in three
150
+ different source files has one home, the same as the dead state above.
151
+
152
+ Why these four and not everything that scrolls. The strip is the test: a
153
+ rail is 208px wide and a palette list sits in a small floating panel, so the
154
+ bar costs a measurable share of the width AND draws a second vertical line
155
+ beside the object's own edge. A table's horizontal bar is the opposite case
156
+ and .axi-table__scroll keeps it on purpose (see src/data.css) - there the bar
157
+ is the only thing telling you a column is off-screen. Nothing here reaches a
158
+ descendant: `*` would take the table's bar away the moment someone put one
159
+ in a rail.
160
+
161
+ `.axi-rail` and `.axi-rail__nav` both, because either can be the scroller.
162
+ The rail declares `overflow-y: auto` and is the scroller by default, but a
163
+ rail with something pinned below its list - a "back" action at the floor -
164
+ scrolls the nav instead and leaves the rail still. Both are the same strip
165
+ and the decision cannot depend on which one the consumer chose. */
166
+ .axi-scroll-quiet,
167
+ .axi-palette__list,
168
+ .axi-rail,
169
+ .axi-rail__nav,
170
+ .axi-split__nav {
171
+ scrollbar-width: none;
172
+ }
173
+ .axi-scroll-quiet::-webkit-scrollbar,
174
+ .axi-palette__list::-webkit-scrollbar,
175
+ .axi-rail::-webkit-scrollbar,
176
+ .axi-rail__nav::-webkit-scrollbar,
177
+ .axi-split__nav::-webkit-scrollbar {
178
+ width: 0;
179
+ height: 0;
180
+ }