@axiapps/axi-design 1.43.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.
package/dist/axi.css CHANGED
@@ -425,13 +425,69 @@ a.axi-panel--tile:hover,
425
425
  button.axi-panel--tile:hover,
426
426
  .axi-panel--tile[role='button']:hover { box-shadow: var(--axi-shadow-control-hover); }
427
427
 
428
+ /* The one of these you picked. A grid of panels, tiles or cards where one is
429
+ chosen - a theme picker, a fight picker, a leader you have opened - and the
430
+ surface has to say which.
431
+
432
+ Not the accent fill a rail item takes. A rail item is a word, and a word can
433
+ be printed on the accent; a panel carries content, and an accent fill drowns
434
+ it. The answer is the third weight .axi-rail__nav--quiet already found for
435
+ the same reason: the surface rises and the accent arrives on the edge. A
436
+ whole edge here rather than the leading one, because a grid is read in two
437
+ directions and has no leading side to light.
438
+
439
+ Keyed off what the element already says, the way .axi-panel--tile keys its
440
+ press off being an <a> or a <button> rather than off a second class. The
441
+ language has one spelling per selection semantics, and this rule takes all
442
+ of them rather than inventing a fourth: `aria-current` for a place you
443
+ navigated to (a rail item, a tab, a table row), `aria-pressed` for a toggle
444
+ (a pill), `aria-selected` for an option in a listbox (a picker option). A
445
+ consumer that marks a card by appending a border utility has told assistive
446
+ tech nothing at all, and that is the defect this replaces rather than the
447
+ missing fill - ten sites in one consumer, six different bases, three
448
+ invented extra marks, and not one of them announced.
449
+
450
+ The fourth arm is a <label> wrapping its own radio, which is the correct
451
+ markup for a picker made of panels: the input carries the state, so the
452
+ label has no attribute to set and copying the state onto one would be a
453
+ second source of truth that can disagree. This is the language's only
454
+ :has(), and the child combinator is the whole point - `> input:checked`
455
+ means this panel IS the control, where a descendant match would light a
456
+ panel up for any checkbox buried in its content.
457
+
458
+ Both spellings on one rule, per "a style only reachable through a layer will
459
+ be re-invented": .axi-card is a shell and .axi-panel a primitive, and a
460
+ consumer marking one of them must not have to find out which file its
461
+ selected state was written in. The fill is the `background` shorthand and
462
+ not the -paint companion, because --axi-surface-raised is a surface token
463
+ and a theme is allowed to make a surface a gradient. */
464
+ :is(.axi-card, .axi-panel):is(
465
+ [aria-current],
466
+ [aria-pressed='true'],
467
+ [aria-selected='true'],
468
+ :has(> input:checked)
469
+ ) {
470
+ background: var(--axi-surface-raised);
471
+ border-color: var(--axi-accent);
472
+ }
473
+
428
474
  /* ---------- well ---------- */
429
475
  /* The panel's inverse: a field sunk into the surface around it rather than
430
476
  raised off it. No block, because a recess casts nothing, and the rule rather
431
477
  than the ink for its edge - see --axi-well-fill in src/tokens.css for why
432
478
  both of those follow from the same fact. Reach for this wherever a second
433
479
  panel inside a panel was the instinct: a picker list beside a table, a column
434
- of cards standing on the page, a trough a bar fills. */
480
+ of cards standing on the page, a trough a bar fills.
481
+
482
+ .axi-split__nav is the first of those three named as a part rather than left
483
+ to the consumer to remember, and it is in THIS rule rather than restating the
484
+ fill next to the split pane. Two rules that agree today are two rules that
485
+ disagree after the next edit - see "A style only reachable through a layer
486
+ will be re-invented" in docs/RULES.md, which is the same argument arriving
487
+ from the other direction. The split pane's slot cannot be a well by
488
+ convention: a consumer who forgets the second class gets a picker with no
489
+ recess, and the language has no way to notice. */
490
+ .axi-split__nav,
435
491
  .axi-well {
436
492
  background: var(--axi-well-fill);
437
493
  border: var(--axi-border-control) solid var(--axi-well-line);
@@ -530,6 +586,43 @@ button.axi-panel--tile:hover,
530
586
  icon-only button has no label to pad around, so the 20px sides meant for
531
587
  one produced a wide rectangle around a single glyph - visible in this
532
588
  language's own gallery, in the example titled "Alone in a button". */
589
+ /* Icon over label rather than beside it. Not a style choice - it is the only
590
+ thing that makes a bar of equal actions fit a phone. Measured on the
591
+ consumer that needed it: four icon+label actions side by side want 387px of
592
+ the 337px available at 393px wide, and every label is one unbreakable word,
593
+ so min-content equals max-content and flex-shrink has nothing to give. The
594
+ last action simply runs off the screen. Stacked, the same four come to
595
+ ~291px.
596
+
597
+ min-width: 0 because that failure has a second half. A flex item's default
598
+ min-width is its content, so a button in a row refuses to shrink below its
599
+ own label even when the row is told to divide the space - which is the same
600
+ trap .axi-tabs__tab documents for its label and .axi-split__body for its
601
+ track.
602
+
603
+ The label is addressed as an element because a stacked button has two
604
+ children and only one of them is words; naming a part would make every
605
+ consumer add a class to say what the span already says. :where() so it
606
+ spends no specificity - the ink layer and any modifier still out-rank it,
607
+ which is the contract src/utilities.css is owed. */
608
+ .axi-btn--stack {
609
+ flex-direction: column;
610
+ gap: 5px;
611
+ min-width: 0;
612
+ }
613
+ .axi-btn--stack > :where(span) {
614
+ /* Both, and the pair is the point: max-width caps it against the button,
615
+ min-width: 0 is what lets it shrink at all. A flex item's default
616
+ min-width is its content, so max-width alone leaves the ellipsis
617
+ unreachable - the label overflows the cap instead of truncating inside
618
+ it. tests/tokens.test.mjs pins the pair for every truncating rule in the
619
+ language, and it caught this one. */
620
+ max-width: 100%;
621
+ min-width: 0;
622
+ overflow: hidden;
623
+ text-overflow: ellipsis;
624
+ white-space: nowrap;
625
+ }
533
626
  .axi-btn--sm { --axi-btn-pad: 8px 13px; --axi-btn-size: 12px; }
534
627
  .axi-btn--xs { --axi-btn-pad: 5px 9px; --axi-btn-size: 11px; }
535
628
  .axi-btn--icon { --axi-btn-pad: 7px; }
@@ -828,7 +921,7 @@ button.axi-panel--tile:hover,
828
921
  says `axi-link axi-ink-dim` must stay dim under the cursor, and the plain
829
922
  `:hover` form is what made the danger button read white under one. */
830
923
  .axi-link,
831
- .axi-prose a {
924
+ .axi-prose :where(a) {
832
925
  padding: 0;
833
926
  background: none;
834
927
  border: 0;
@@ -841,7 +934,7 @@ button.axi-panel--tile:hover,
841
934
  cursor: pointer;
842
935
  }
843
936
  .axi-link:where(:hover),
844
- .axi-prose a:where(:hover) { color: var(--axi-text); }
937
+ .axi-prose :where(a:hover) { color: var(--axi-text); }
845
938
 
846
939
  /* ---------- quiet action ---------- */
847
940
  /* A control that is only its label. Not a button - it draws no box, takes no
@@ -967,8 +1060,8 @@ button.axi-panel--tile:hover,
967
1060
  disclosure that drops back flat the moment the pointer moves into the list
968
1061
  it opened severs the two: the lift is what says this box and that popover
969
1062
  are one control. */
970
- .axi-select:hover,
971
- .axi-picker__btn:hover,
1063
+ .axi-select:where(:hover),
1064
+ .axi-picker__btn:where(:hover),
972
1065
  .axi-picker__btn[aria-expanded='true'] {
973
1066
  color: var(--axi-text);
974
1067
  box-shadow: var(--axi-shadow-control);
@@ -995,7 +1088,7 @@ button.axi-panel--tile:hover,
995
1088
  backdrop-filter: var(--axi-surface-filter);
996
1089
  box-shadow: var(--axi-shadow-panel);
997
1090
  }
998
- .axi-select option {
1091
+ .axi-select :where(option) {
999
1092
  display: flex; align-items: center; gap: 9px;
1000
1093
  padding: 8px 9px;
1001
1094
  border-radius: var(--axi-radius-sm);
@@ -1010,7 +1103,7 @@ button.axi-panel--tile:hover,
1010
1103
  level's fill - and this popover is filled with --axi-surface-raised, which
1011
1104
  a theme may take translucent, so an opaque row here is the black patch
1012
1105
  --axi-well-fill exists to prevent. Same for the picker's rows below. */
1013
- .axi-select option:hover, .axi-select option:focus {
1106
+ .axi-select :where(option:hover, option:focus) {
1014
1107
  background: var(--axi-well-fill); color: var(--axi-text);
1015
1108
  }
1016
1109
  /* The page-wide focus ring sits 2px outside its element; inside a picker
@@ -1109,7 +1202,7 @@ button.axi-panel--tile:hover,
1109
1202
  color: transparent;
1110
1203
  font-weight: 900;
1111
1204
  }
1112
- .axi-picker__opt:hover, .axi-picker__opt:focus {
1205
+ .axi-picker__opt:where(:hover, :focus) {
1113
1206
  background: var(--axi-well-fill); color: var(--axi-text);
1114
1207
  }
1115
1208
  /* The page-wide focus ring sits 2px outside its element; inside a popover
@@ -1296,6 +1389,68 @@ textarea.axi-input {
1296
1389
  /* A horizontal run that wraps rather than overflowing. */
1297
1390
  .axi-row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--axi-row-gap, 10px); }
1298
1391
 
1392
+ /* ---------- split pane ---------- */
1393
+ /* A picker on the left choosing what the surface on the right shows. Thirteen
1394
+ sections of one consumer are this shape, and a fourteenth had hand-built it
1395
+ inline because there was no name for it.
1396
+
1397
+ It is two objects on one plane and not two panels, which is the whole reason
1398
+ it needs naming: a picker list beside a table is the first case --axi-well's
1399
+ own comment names, and the thing the list picks is the raised counterpart to
1400
+ that recess. Drawn as two panels it read flat - a hairline parting one field,
1401
+ with the list's scrollbar carving a groove down the middle of it - because
1402
+ nothing in it was an object.
1403
+
1404
+ The body carries the CONTROL step, not the panel's. A split pane lives
1405
+ inside a panel that has already paid a 6px block, and rule 8's counterpart
1406
+ settles what a second one nested in it reads as: two planes arguing. The
1407
+ consumer that derived this shape by hand got the outline and left out the
1408
+ block entirely, which is a rule 3 failure in the other direction - a
1409
+ boundary drawn around content that is standing on the surface behind it.
1410
+ Outlined and blocked, at the weight that says "the contents of a box".
1411
+
1412
+ The radius follows the form step and not the footprint. The body is
1413
+ panel-sized in area and control-weight in form, and .axi-panel--tile already
1414
+ settled that argument the same way.
1415
+
1416
+ `minmax(0, 1fr)` rather than `1fr`: a table in a `1fr` track cannot shrink
1417
+ below its own content, and every cell in .axi-table is `nowrap`, so the
1418
+ column blows out and takes the pane's width with it. `1fr` is
1419
+ `minmax(auto, 1fr)` and `auto` is a content floor.
1420
+
1421
+ No breakpoint of its own. Under 640px the two stack, which is layout.css's
1422
+ one breakpoint and deliberately still its only one - .axi-table--fixed
1423
+ already makes a 360px pane work, so a pane narrow enough to need a second
1424
+ breakpoint is one --fixed was written for. */
1425
+ .axi-split {
1426
+ display: grid;
1427
+ grid-template-columns: var(--axi-split-nav-w, 280px) minmax(0, 1fr);
1428
+ gap: var(--axi-split-gap, 10px);
1429
+ /* A split pane is routinely the flex child that fills a section's height,
1430
+ and a grid that cannot shrink below its content pushes the section past
1431
+ its own cap - the same reason .axi-palette__list says it. */
1432
+ min-height: 0;
1433
+ }
1434
+ /* The picker is a well, declared as one in the same rule as .axi-well in
1435
+ src/primitives.css rather than restated here. This part only says what the
1436
+ slot adds: it scrolls, and it may not out-grow the row it sits in. Its
1437
+ scrollbar is hidden by the language's quiet-scroll rule in
1438
+ src/utilities.css, which is where that decision lives for every strip. */
1439
+ .axi-split__nav { overflow-y: auto; min-height: 0; }
1440
+ .axi-split__body {
1441
+ /* Paired with the track's minmax(0, 1fr): the track stops the COLUMN
1442
+ growing, this stops the ITEM, whose min-width is `auto` by default and is
1443
+ what an ellipsis inside it is actually fighting. */
1444
+ min-width: 0;
1445
+ min-height: 0;
1446
+ overflow: hidden;
1447
+ background: var(--axi-surface);
1448
+ backdrop-filter: var(--axi-surface-filter);
1449
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1450
+ border-radius: var(--axi-radius-sm);
1451
+ box-shadow: var(--axi-shadow-control);
1452
+ }
1453
+
1299
1454
  @media (max-width: 640px) {
1300
1455
  /* The fallback must match the resting rule's (var(--axi-gutter), 18px) -
1301
1456
  README.md documents --axi-page-pad's fallback as --axi-gutter, and a
@@ -1303,6 +1458,12 @@ textarea.axi-input {
1303
1458
  mobile, which is the one viewport where the gutter matters most. */
1304
1459
  .axi-page { padding-inline: var(--axi-page-pad, var(--axi-gutter)); }
1305
1460
  .axi-grid { grid-template-columns: 1fr; }
1461
+ /* The picker stops being a column beside the thing it picks and becomes the
1462
+ row above it. It keeps a height cap - a picker that grows to twenty rows
1463
+ pushes the result it picked off the screen, which is the one thing the
1464
+ stacked form must not do. */
1465
+ .axi-split { grid-template-columns: 1fr; }
1466
+ .axi-split__nav { max-height: var(--axi-split-nav-h, 200px); }
1306
1467
  }
1307
1468
 
1308
1469
  /* --- shells.css --- */
@@ -1311,14 +1472,23 @@ textarea.axi-input {
1311
1472
  redefining them, so a change to .axi-panel moves the notice, the toolbar,
1312
1473
  the popover and the card together. */
1313
1474
 
1314
- /* The small uppercase heading that labels a panel's contents. */
1475
+ /* The small uppercase heading that labels a panel's contents.
1476
+
1477
+ The gap under it is a knob because the eyebrow heads more than one size of
1478
+ thing. 14px is the figure for a panel at its resting padding; a tile is one
1479
+ form step down and its padding is one density step down with it, so the
1480
+ eyebrow inside one reads the tighter figure in the rule below, the same way
1481
+ .axi-panel--tile reads --axi-panel-pad at a tighter fallback of its own. An
1482
+ eyebrow sitting in a row - a sheet's head, a toolbar - wants no gap at all,
1483
+ and says so with the knob rather than with an inline margin. */
1315
1484
  .axi-eyebrow {
1316
- margin: 0 0 14px;
1485
+ margin: 0 0 var(--axi-eyebrow-gap, 14px);
1317
1486
  font: var(--axi-t-eyebrow);
1318
1487
  letter-spacing: var(--axi-ls-eyebrow);
1319
1488
  text-transform: uppercase;
1320
1489
  color: var(--axi-text-faint);
1321
1490
  }
1491
+ .axi-panel--tile .axi-eyebrow { margin-bottom: var(--axi-eyebrow-gap, 6px); }
1322
1492
 
1323
1493
  /* ---------- masthead ---------- */
1324
1494
  /* Opaque, because content scrolls under it. Which is also why it paints the
@@ -1403,9 +1573,35 @@ textarea.axi-input {
1403
1573
  it shrinks an item - so one tab too many sent the last item in source order
1404
1574
  to a second row instead of taking width off the widest one. The strip is the
1405
1575
  item that can give: it scrolls, and the row stays a row. */
1406
- .axi-mast .axi-tabs { margin-left: auto; flex-shrink: 1; min-width: 0; overflow-x: auto; }
1407
- .axi-tabs a,
1408
- .axi-tabs button {
1576
+ .axi-mast .axi-tabs { margin-left: auto; }
1577
+ /* A strip with more tabs than room scrolls rather than losing them.
1578
+
1579
+ .axi-tabs is `display: flex` with no wrap, so a strip too wide for its
1580
+ container overflows - and outside a scroll container the tabs past the edge
1581
+ are simply unreachable. The language already believed this twice and could
1582
+ say it in neither place a consumer could use: once here, behind .axi-mast,
1583
+ and once at the 640px breakpoint below, where EVERY strip scrolls. A
1584
+ standalone strip above that breakpoint with one tab too many had nowhere to
1585
+ look, and the consumer that hit it wrote `overflow-x: auto` on its own
1586
+ wrapper - the same re-invention docs/RULES.md describes, arriving a fourth
1587
+ time.
1588
+
1589
+ A modifier rather than the base rule, and this is the part worth arguing.
1590
+ `overflow-x: auto` computes `overflow-y` from `visible` to `auto`, so a
1591
+ strip that scrolls is also a strip that clips vertically - a focus ring, a
1592
+ badge hanging off a tab, a popover anchored to one. Every strip paying that
1593
+ to fix the strips that are too long is the wrong trade; the strips that are
1594
+ too long say so.
1595
+
1596
+ The bar is NOT hidden here, and the quiet-scroll rule in src/utilities.css
1597
+ deliberately does not name this. That rule is about a bar drawn down the
1598
+ side of a narrow strip, where the object's own edge is already a vertical
1599
+ line and a count elsewhere already says how many rows there are. A tab strip
1600
+ that scrolls horizontally has no second signal: the bar is the only thing
1601
+ saying there are more tabs. Same reason .axi-table__scroll keeps its. */
1602
+ .axi-tabs--scroll,
1603
+ .axi-mast .axi-tabs { flex-shrink: 1; min-width: 0; overflow-x: auto; }
1604
+ .axi-tabs :where(a, button) {
1409
1605
  padding: 8px 16px;
1410
1606
  font: 800 13.5px/1 var(--axi-sans);
1411
1607
  color: var(--axi-text-dim); text-decoration: none;
@@ -1414,8 +1610,7 @@ textarea.axi-input {
1414
1610
  border: var(--axi-border-control) solid transparent;
1415
1611
  border-radius: var(--axi-radius-sm);
1416
1612
  }
1417
- .axi-tabs a:hover,
1418
- .axi-tabs button:hover { color: var(--axi-text); border-color: var(--axi-rule); }
1613
+ .axi-tabs :where(a:hover, button:hover) { color: var(--axi-text); border-color: var(--axi-rule); }
1419
1614
  /* The current tab is filled and blocked - the same treatment a pressed pill
1420
1615
  gets, because it is the same idea: this one is on. */
1421
1616
  .axi-tabs a[aria-current="page"],
@@ -1447,13 +1642,13 @@ textarea.axi-input {
1447
1642
  text-overflow: ellipsis;
1448
1643
  white-space: nowrap;
1449
1644
  }
1450
- .axi-tabs .axi-tabs__close {
1645
+ .axi-tabs :where(.axi-tabs__close) {
1451
1646
  padding: 0 6px;
1452
1647
  font: var(--axi-t-micro);
1453
1648
  color: var(--axi-text-faint);
1454
1649
  border-color: transparent;
1455
1650
  }
1456
- .axi-tabs .axi-tabs__close:hover { color: var(--axi-text); border-color: transparent; }
1651
+ .axi-tabs :where(.axi-tabs__close:hover) { color: var(--axi-text); border-color: transparent; }
1457
1652
 
1458
1653
  /* ---------- rail ---------- */
1459
1654
  /* Vertical navigation, for a set too large for a tab strip to hold. Same
@@ -1527,7 +1722,7 @@ textarea.axi-input {
1527
1722
  border-radius: var(--axi-radius-sm);
1528
1723
  cursor: pointer;
1529
1724
  }
1530
- .axi-rail__item:hover { color: var(--axi-text); background: var(--axi-surface-raised); }
1725
+ .axi-rail__item:where(:hover) { color: var(--axi-text); background: var(--axi-surface-raised); }
1531
1726
  /* The same treatment a pressed pill and a current tab get. Only ever one of
1532
1727
  these on screen. Any aria-current value, because the right one depends on
1533
1728
  what the rail navigates - `page` for a view, `location` for a place within
@@ -1590,7 +1785,7 @@ textarea.axi-input {
1590
1785
  border-radius: var(--axi-radius-sm);
1591
1786
  cursor: pointer;
1592
1787
  }
1593
- .axi-rail__subitem:hover { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
1788
+ .axi-rail__subitem:where(:hover) { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
1594
1789
  .axi-rail__subitem[aria-current] { color: var(--axi-text); }
1595
1790
  /* An annotation on an item, not a measurement of it, so rule 5 draws it
1596
1791
  outlined rather than filled. */
@@ -1738,13 +1933,13 @@ textarea.axi-input {
1738
1933
  box-shadow: var(--axi-shadow-panel);
1739
1934
  }
1740
1935
  .axi-menu__pop[hidden] { display: none; }
1741
- .axi-menu__pop label {
1936
+ .axi-menu__pop :where(label) {
1742
1937
  display: flex; gap: 9px; align-items: flex-start;
1743
1938
  padding: 7px 8px; border-radius: var(--axi-radius-sm);
1744
1939
  font-size: 13px; font-weight: 600; color: var(--axi-text-dim); cursor: pointer;
1745
1940
  }
1746
1941
  /* A hovered row sinks - the same fill the select's and the picker's rows use. */
1747
- .axi-menu__pop label:hover { background: var(--axi-well-fill); color: var(--axi-text); }
1942
+ .axi-menu__pop :where(label:hover) { background: var(--axi-well-fill); color: var(--axi-text); }
1748
1943
  .axi-menu__pop input { margin: 3px 0 0; }
1749
1944
 
1750
1945
  /* ---------- command palette ---------- */
@@ -1805,14 +2000,16 @@ textarea.axi-input {
1805
2000
  }
1806
2001
  /* min-height: 0 because the panel is a flex column and a list that cannot
1807
2002
  shrink below its content pushes the panel past its own max-height. The
1808
- scrollbar is hidden: at twelve rows behind a fixed cap the bar is a channel
1809
- carved down the side of a small panel to report a fact the row count in the
1810
- bar has already given. */
2003
+ scrollbar is hidden, but not here: that decision is the language's and not
2004
+ this component's, and it now lives once, in src/utilities.css under "the
2005
+ quiet scroll", with this selector in the same rule as every other strip that
2006
+ makes it. The argument that produced it was written here first - at twelve
2007
+ rows behind a fixed cap the bar is a channel carved down the side of a small
2008
+ panel to report a fact the row count in the bar has already given - and it is
2009
+ preserved in full at the rule. */
1811
2010
  .axi-palette__list {
1812
2011
  flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0;
1813
- scrollbar-width: none;
1814
2012
  }
1815
- .axi-palette__list::-webkit-scrollbar { width: 0; height: 0; }
1816
2013
  /* One .axi-palette__section per group of rows, each opening with a
1817
2014
  .axi-palette__group heading. The section is a named part rather than left to
1818
2015
  the consumer's wrapper so the rule between groups can be stated as what it
@@ -1834,7 +2031,7 @@ textarea.axi-input {
1834
2031
  color: var(--axi-text-dim);
1835
2032
  font: var(--axi-t-small); text-align: left; cursor: pointer;
1836
2033
  }
1837
- .axi-palette__row:hover { background: var(--axi-surface-raised); color: var(--axi-text); }
2034
+ .axi-palette__row:where(:hover) { background: var(--axi-surface-raised); color: var(--axi-text); }
1838
2035
  .axi-palette__row[data-active] { background: var(--axi-accent); color: var(--axi-accent-ink); }
1839
2036
  /* The row's own ink is set above, but a row carries a glyph and a second line
1840
2037
  of text that were coloured on their own; on the filled row they all become
@@ -1866,7 +2063,7 @@ textarea.axi-input {
1866
2063
  cursor: pointer;
1867
2064
  transition: transform .1s, box-shadow .1s, color .1s;
1868
2065
  }
1869
- .axi-palette__trigger:hover {
2066
+ .axi-palette__trigger:where(:hover) {
1870
2067
  color: var(--axi-text);
1871
2068
  box-shadow: var(--axi-shadow-control);
1872
2069
  transform: translate(-2px, -2px);
@@ -2013,7 +2210,7 @@ textarea.axi-input {
2013
2210
  color: var(--axi-text-faint);
2014
2211
  }
2015
2212
  .axi-card__go { margin-left: auto; color: var(--axi-accent); font-weight: 900; text-transform: uppercase; letter-spacing: .05em; }
2016
- .axi-card:hover .axi-card__go { color: var(--axi-text); }
2213
+ .axi-card:where(:hover) :where(.axi-card__go) { color: var(--axi-text); }
2017
2214
 
2018
2215
  /* ---------- drawer ---------- */
2019
2216
  /* A scrim's layer is not a property of the scrim. It is "directly below the
@@ -2086,7 +2283,7 @@ textarea.axi-input {
2086
2283
  font-size: 15px; font-weight: 900; cursor: pointer;
2087
2284
  transition: transform .1s, box-shadow .1s;
2088
2285
  }
2089
- .axi-drawer__close:hover {
2286
+ .axi-drawer__close:where(:hover) {
2090
2287
  background: var(--axi-accent); color: var(--axi-accent-ink);
2091
2288
  box-shadow: var(--axi-shadow-control);
2092
2289
  transform: translate(-2px, -2px);
@@ -2228,7 +2425,7 @@ textarea.axi-input {
2228
2425
  -webkit-app-region: drag;
2229
2426
  }
2230
2427
  .axi-titlebar__btns { margin-left: auto; display: flex; height: 100%; -webkit-app-region: no-drag; }
2231
- .axi-titlebar__btns button {
2428
+ .axi-titlebar__btns :where(button) {
2232
2429
  width: 44px;
2233
2430
  border: 0;
2234
2431
  background: transparent;
@@ -2239,10 +2436,10 @@ textarea.axi-input {
2239
2436
  /* No lift here. A window button is chrome, not content: it has nothing to
2240
2437
  lift off, and 44px of movement in the corner of every screen is exactly
2241
2438
  the scattered motion rule 4 rations. It answers on the neutral ramp. */
2242
- .axi-titlebar__btns button:hover { background: var(--axi-surface); color: var(--axi-text); }
2439
+ .axi-titlebar__btns :where(button:hover) { background: var(--axi-surface); color: var(--axi-text); }
2243
2440
  /* Close is the one destructive control in the chrome, and the one place a
2244
2441
  status ink belongs in a titlebar. */
2245
- .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
2442
+ .axi-titlebar__btns :where(button:last-child:hover) { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
2246
2443
  /* The scrolling region under the chrome. */
2247
2444
  .axi-window__body { flex: 1; overflow-y: auto; }
2248
2445
 
@@ -2261,8 +2458,8 @@ textarea.axi-input {
2261
2458
  flex-wrap: wrap;
2262
2459
  font: var(--axi-t-small);
2263
2460
  }
2264
- .axi-crumbs a { color: var(--axi-text-dim); text-decoration: none; }
2265
- .axi-crumbs a:hover { color: var(--axi-accent); }
2461
+ .axi-crumbs :where(a) { color: var(--axi-text-dim); text-decoration: none; }
2462
+ .axi-crumbs :where(a:hover) { color: var(--axi-accent); }
2266
2463
  /* 7px, matching the prose bullet (src/prose.css) exactly, with the same
2267
2464
  --axi-border-hairline outline: rule 7 calls the motif "a 45deg-rotated
2268
2465
  outlined square", and a bare fill with no border does not match it. The
@@ -2296,7 +2493,7 @@ textarea.axi-input {
2296
2493
  border: var(--axi-border-control) solid var(--axi-ink-line);
2297
2494
  border-radius: var(--axi-radius-sm);
2298
2495
  }
2299
- .axi-pages__n:hover { color: var(--axi-text); }
2496
+ .axi-pages__n:where(:hover) { color: var(--axi-text); }
2300
2497
  .axi-pages__n[aria-current="page"] {
2301
2498
  background: var(--axi-accent);
2302
2499
  color: var(--axi-accent-ink);
@@ -2309,7 +2506,12 @@ textarea.axi-input {
2309
2506
  @media (max-width: 640px) {
2310
2507
  .axi-mast__in { min-height: 0; padding: 10px 16px; gap: 12px; }
2311
2508
  .axi-mast .axi-tabs { margin-left: 0; }
2312
- .axi-tabs { width: 100%; overflow-x: auto; }
2509
+ /* Every strip scrolls on a phone, whether or not it asked. This cannot join
2510
+ the shared rule above - a media-query rule and an unconditional one are
2511
+ different rules by construction - so the values are pinned against it by
2512
+ test instead: the arm may declare nothing the modifier does not, and
2513
+ nothing at a different value. */
2514
+ .axi-tabs { width: 100%; flex-shrink: 1; min-width: 0; overflow-x: auto; }
2313
2515
  /* The offset blocks eat horizontal room a phone does not have, so the panel
2314
2516
  step drops to the control step here rather than to an invented number.
2315
2517
  The card's hover has to come down with it - left alone it kept deepening
@@ -2601,7 +2803,17 @@ textarea.axi-input {
2601
2803
  resting cell rule (`.axi-table :where(td, tbody th)`, also one class) and
2602
2804
  wins on order, while an ink class in utilities.css - one class, later file -
2603
2805
  wins over both. */
2604
- .axi-table :where(tbody tr:hover) :where(td, th) {
2806
+ /* A DATA FILL SURVIVES A ROW STATE, and the `:not()` sits inside `:where()`
2807
+ so saying so costs no specificity. A matrix cell's band IS its value (rule 9,
2808
+ "the matrix is the bound on not at all") - washing it on the way past the row
2809
+ you want deletes the reading. The row state is not weakened by this: it is
2810
+ drawn by the leading edge below, and by every cell that has nothing to say.
2811
+ Which is the edge-not-fill answer the selection rule below reaches on its own
2812
+ grounds, arriving here a second time.
2813
+
2814
+ Nothing changes for a table with no `data-heat` in it, which is every table
2815
+ in this language but one. */
2816
+ .axi-table :where(tbody tr:hover) :where(td:not([data-heat]), th:not([data-heat])) {
2605
2817
  background: var(--axi-surface-raised);
2606
2818
  background-attachment: fixed;
2607
2819
  color: var(--axi-text);
@@ -2636,7 +2848,7 @@ textarea.axi-input {
2636
2848
  .axi-table :where(tbody tr) > :where(td, th):first-child {
2637
2849
  border-inline-start: var(--axi-border-control) solid transparent;
2638
2850
  }
2639
- .axi-table tbody tr[aria-current] :is(td, th) {
2851
+ .axi-table tbody tr[aria-current] :is(td, th):where(:not([data-heat])) {
2640
2852
  background: var(--axi-surface-raised);
2641
2853
  background-attachment: fixed;
2642
2854
  color: var(--axi-text);
@@ -2797,6 +3009,115 @@ textarea.axi-input {
2797
3009
  }
2798
3010
  .axi-table th:first-child .axi-table__sort { justify-content: flex-start; }
2799
3011
 
3012
+ /* ---------- matrix ---------- */
3013
+ /* Two categorical axes and a quantity where they cross: forty players down the
3014
+ side, sixty five-second buckets across, a count in each cell. Rule 9's
3015
+ "the matrix is the bound on not at all" is the whole argument for this
3016
+ existing at all, and the short form is: the plane is spent, so there is no
3017
+ third dimension left to give the quantity a LENGTH, and both axes are
3018
+ already sorted by something the reader needs so neither can be re-sorted by
3019
+ the value. Intensity is what is left, and it is admissible here because the
3020
+ cell also prints its number - the digit is the legible copy rule 9 demands,
3021
+ and the band is only what lets the eye find the shape without reading two
3022
+ thousand figures one at a time. A matrix cell with nothing written in it is
3023
+ a heatmap and rule 9 refuses it.
3024
+
3025
+ This is a MODIFIER and not a component, and that is the finding rather than
3026
+ a convenience. Everything structural a matrix field needs, this table
3027
+ already had: --sticky for the ruler that stays, --pinned for the names that
3028
+ stay, --dense for sixty columns' padding, --fixed plus a <colgroup> for
3029
+ proportional cells, and .axi-table__scroll for the frame they move inside.
3030
+ The consumer measured against here had hand-written all five, and had got
3031
+ --sticky wrong in exactly the way that modifier's comment warns about: its
3032
+ ruler was --axi-surface-raised, so under glass the rows slid through it.
3033
+ The only thing genuinely missing was the quantity.
3034
+
3035
+ What the modifier changes is the CELL, which stops being a number in a
3036
+ column and becomes a patch in a field: centred rather than right-aligned,
3037
+ because a band is read by position and not by its digits lining up, and
3038
+ unpadded because the padding is the field. The name column keeps the base
3039
+ table's left alignment and its gutter - it is still a name. */
3040
+ .axi-table--matrix { border-collapse: separate; border-spacing: 0; }
3041
+ .axi-table--matrix thead th { padding: 0 0 6px; text-align: center; }
3042
+ .axi-table--matrix :is(td, tbody th) {
3043
+ padding: 0;
3044
+ text-align: center;
3045
+ height: var(--axi-matrix-cell, 24px);
3046
+ }
3047
+ /* `min-width` and not `width`: under the default auto layout this is the floor
3048
+ a one-digit column cannot fall below, and under --fixed a <colgroup> takes
3049
+ over entirely (fixed layout ignores both min and max - see --fixed). */
3050
+ .axi-table--matrix :is(td, tbody th):not(:first-child) {
3051
+ min-width: var(--axi-matrix-cell, 24px);
3052
+ }
3053
+ .axi-table--matrix :is(td, tbody th):first-child {
3054
+ text-align: left;
3055
+ padding: 0 10px 0 0;
3056
+ }
3057
+ /* The four bands. Each is a color-mix() of the accent into the surface's flat
3058
+ companion, which makes every one of them a computed OPAQUE colour - the
3059
+ accent at 18% alpha over the field would be rule 2's faded ink, and it also
3060
+ fails on its own terms, because the cells a reader is scanning for are the
3061
+ quiet ones and an alpha ramp is where those disappear.
3062
+
3063
+ --axi-surface-paint and not --axi-surface, and this is the trap: a surface
3064
+ token is allowed to hold a gradient (rule 1's one relief, which is how a
3065
+ glass theme exists), color-mix() takes colours and nothing else, and an
3066
+ invalid color-mix() is dropped at computed-value time. Spell it --axi-surface
3067
+ and the bands do not fade - they vanish, silently, in whichever theme paints
3068
+ a gradient. That is measured, not hypothetical: it is why
3069
+ --axi-surface-paint exists (see tokens.css).
3070
+
3071
+ Four steps and not a continuous ramp, because a band is also where the digit
3072
+ has to change ink, and a step is the only thing a flip can happen ON. The
3073
+ top two carry --axi-accent-ink for the same reason .axi-btn--primary does:
3074
+ past roughly half strength the field is the accent and the text on it is the
3075
+ accent's companion, not the text ramp. */
3076
+ .axi-table--matrix :is(td, tbody th)[data-heat='1'] {
3077
+ background-color: color-mix(in srgb, var(--axi-accent) 18%, var(--axi-surface-paint));
3078
+ }
3079
+ .axi-table--matrix :is(td, tbody th)[data-heat='2'] {
3080
+ background-color: color-mix(in srgb, var(--axi-accent) 42%, var(--axi-surface-paint));
3081
+ }
3082
+ .axi-table--matrix :is(td, tbody th)[data-heat='3'] {
3083
+ background-color: color-mix(in srgb, var(--axi-accent) 70%, var(--axi-surface-paint));
3084
+ color: var(--axi-accent-ink);
3085
+ }
3086
+ .axi-table--matrix :is(td, tbody th)[data-heat='4'] {
3087
+ background-color: color-mix(in srgb, var(--axi-accent) 100%, var(--axi-surface-paint));
3088
+ color: var(--axi-accent-ink);
3089
+ }
3090
+ /* A matrix whose columns are a TIMELINE rather than a list of categories, which
3091
+ changes two things and only two.
3092
+
3093
+ The column labels move left. A label over a category names the column and
3094
+ belongs centred over it; a label on a ruler names a MOMENT, and centring it
3095
+ puts the text half a cell to the right of the instant it is pointing at.
3096
+
3097
+ And the division gets a line. `[data-tick]` marks the columns that carry a
3098
+ label - every thirtieth second, not every bucket, because at a five-second
3099
+ resolution a five-minute fight is sixty columns and a timestamp over each is
3100
+ unreadable at the width a cell allows. The line is the RULE, at the hairline
3101
+ step: it is the same line that parts the rows, continued down the field,
3102
+ which is the whole reason it is not drawn on every column. Sixty ruled
3103
+ columns is a spreadsheet; the bands are meant to be the figure. */
3104
+ .axi-table--ruler thead th { text-align: left; }
3105
+ .axi-table--ruler :is(thead th, td, tbody th)[data-tick] {
3106
+ border-left: var(--axi-border-hairline) solid var(--axi-rule);
3107
+ }
3108
+ /* A change of category down the rows - a subgroup, a team, a date. Heavier than
3109
+ the hairline that parts two rows of one group, and heavier in INK rather than
3110
+ in weight: the same line, drawn darker. Going up a form step instead would
3111
+ put a control-weight line inside running content, which is rule 8's grid of
3112
+ boxes, and the boundary would then compete with the head's own lid.
3113
+
3114
+ An attribute and not a class, per rule 13, and on the row because that is
3115
+ what starts: the consumer compares one row's category with the previous
3116
+ row's, which is a fact only it can know. */
3117
+ .axi-table tbody tr[data-group-start] > :is(td, th) {
3118
+ border-top: var(--axi-border-hairline) solid var(--axi-ink-line);
3119
+ }
3120
+
2800
3121
  /* ---------- meter ---------- */
2801
3122
  /* Rule 9: a proportion is a length. The track is the ground, the fill is the
2802
3123
  value, and the fill is one ink at full strength - a tinted or faded bar is
@@ -2846,6 +3167,61 @@ textarea.axi-input {
2846
3167
  font-variant-numeric: tabular-nums;
2847
3168
  }
2848
3169
 
3170
+ /* ---------- readout ---------- */
3171
+ /* A short run of labelled readings: logs seen, uploaded, failed; the session's
3172
+ start and its length; a setting and the switch that sets it. The shape of
3173
+ every status card in a dashboard's side column, and the one the consumer
3174
+ that had four of them built by hand each time.
3175
+
3176
+ Rule 8 settles how it is drawn. The eye runs down the value column - that is
3177
+ the whole point of stacking the readings - so it is a table's interior:
3178
+ rows parted by the rule at the hairline weight, no outline and no block on
3179
+ any row, nothing raised inside the panel that is already the raised thing.
3180
+ It is not a table element because it is two cells wide, has no head, and
3181
+ the reading in the second cell is as often a control as a figure; a <dl> is
3182
+ what the document structure calls for and the classes sit on it.
3183
+
3184
+ The key is the meter list's name and the value is the meter list's value,
3185
+ restated here rather than aliased because the two lists are laid out
3186
+ differently and rule-8's test - is there a column? - is the same for both.
3187
+
3188
+ Row padding is a knob at two fallbacks, like the eyebrow's gap: 7px at
3189
+ panel density, and 3px inside a tile, where a hairline already holds the
3190
+ rows apart and 3px is still a clear gap at this type size. */
3191
+ .axi-readout {
3192
+ display: flex;
3193
+ flex-direction: column;
3194
+ margin: 0;
3195
+ }
3196
+ .axi-readout__row {
3197
+ display: flex;
3198
+ align-items: center;
3199
+ justify-content: space-between;
3200
+ gap: 10px;
3201
+ padding: var(--axi-readout-pad, 7px) 0;
3202
+ }
3203
+ .axi-readout__row + .axi-readout__row {
3204
+ border-top: var(--axi-border-hairline) solid var(--axi-rule);
3205
+ }
3206
+ .axi-readout__k {
3207
+ min-width: 0;
3208
+ margin: 0;
3209
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3210
+ font: var(--axi-t-small);
3211
+ font-weight: 700;
3212
+ color: var(--axi-text-dim);
3213
+ }
3214
+ .axi-readout__v {
3215
+ flex: none;
3216
+ margin: 0;
3217
+ text-align: right;
3218
+ font: var(--axi-t-micro);
3219
+ letter-spacing: var(--axi-ls-micro);
3220
+ color: var(--axi-text);
3221
+ font-variant-numeric: tabular-nums;
3222
+ }
3223
+ .axi-panel--tile .axi-readout__row { padding-block: var(--axi-readout-pad, 3px); }
3224
+
2849
3225
  /* ---------- bars ---------- */
2850
3226
  /* The same rule stood on end. The baseline is drawn at the control weight
2851
3227
  because it is an axis - the one line in a chart that is structure rather
@@ -2975,6 +3351,34 @@ textarea.axi-input {
2975
3351
  text-transform: uppercase;
2976
3352
  color: var(--axi-text-dim);
2977
3353
  }
3354
+ /* A key you can press, to isolate the series it names. The state is the
3355
+ attribute rule 13 asks for: `aria-pressed="true"` on the key that is
3356
+ isolated, and nothing at all on the others - which of them recede is the
3357
+ legend's to work out, from whether any key is pressed, rather than a second
3358
+ attribute the consumer has to compute and keep in step.
3359
+
3360
+ Receding is a step down the neutral ramp and not an opacity, which is rule
3361
+ 4's "nothing fades" reaching the legend: the consumer this was written for
3362
+ had the rest of the keys at `opacity: .3` and they were unreadable, which
3363
+ is the one thing a legend must not be. A receded key brightens back to
3364
+ plain under the cursor so it can be found again - the hover is wrapped so it
3365
+ costs nothing against an ink, and the receded rule steps aside for it.
3366
+
3367
+ The button reset and the hover are :where()-wrapped for the reason the ink
3368
+ layer gives: an inked key keeps its ink. The pressed and receded colours are
3369
+ the state's own meaning and stay at full weight. */
3370
+ .axi-legend__key:where(button) {
3371
+ padding: 0;
3372
+ background: none;
3373
+ border: 0;
3374
+ cursor: pointer;
3375
+ transition: color .1s;
3376
+ }
3377
+ .axi-legend__key:where(button:hover) { color: var(--axi-text); }
3378
+ .axi-legend__key[aria-pressed="true"] { color: var(--axi-text); }
3379
+ .axi-legend:has([aria-pressed="true"]) .axi-legend__key:where(:not([aria-pressed="true"], :hover)) {
3380
+ color: var(--axi-text-faint);
3381
+ }
2978
3382
 
2979
3383
  /* --- feedback.css --- */
2980
3384
  /* axi design language - feedback.
@@ -3147,8 +3551,8 @@ textarea.axi-input {
3147
3551
 
3148
3552
  /* --- utilities.css --- */
3149
3553
  /* axi design language - utilities.
3150
- Two families of single-purpose class and one state, and the file is last in
3151
- the cascade
3554
+ Two families of single-purpose class, one state and one refusal, and the file
3555
+ is last in the cascade
3152
3556
  because that is the whole point of it: a utility has to be able to overrule
3153
3557
  the component it sits inside, and it is not allowed to buy that with
3154
3558
  !important or with a padded selector.
@@ -3251,6 +3655,7 @@ textarea.axi-input {
3251
3655
  .axi-chip--action:is(:disabled, [aria-disabled="true"]),
3252
3656
  .axi-drawer__close:is(:disabled, [aria-disabled="true"]),
3253
3657
  .axi-input:is(:disabled, [aria-disabled="true"]),
3658
+ .axi-legend__key:where(button):is(:disabled, [aria-disabled="true"]),
3254
3659
  .axi-link:is(:disabled, [aria-disabled="true"]),
3255
3660
  .axi-palette__row:is(:disabled, [aria-disabled="true"]),
3256
3661
  .axi-palette__trigger:is(:disabled, [aria-disabled="true"]),
@@ -3263,11 +3668,65 @@ textarea.axi-input {
3263
3668
  .axi-select:is(:disabled, [aria-disabled="true"]),
3264
3669
  .axi-switch:is(:disabled, [aria-disabled="true"]),
3265
3670
  .axi-table__sort:is(:disabled, [aria-disabled="true"]),
3266
- .axi-menu__pop label:is(:disabled, [aria-disabled="true"]),
3267
- .axi-prose a:is(:disabled, [aria-disabled="true"]),
3268
- .axi-tabs a:is(:disabled, [aria-disabled="true"]),
3269
- .axi-tabs button:is(:disabled, [aria-disabled="true"]),
3270
- .axi-titlebar__btns button:is(:disabled, [aria-disabled="true"]) {
3671
+ .axi-menu__pop :where(label):is(:disabled, [aria-disabled="true"]),
3672
+ .axi-prose :where(a):is(:disabled, [aria-disabled="true"]),
3673
+ .axi-tabs :where(a, button):is(:disabled, [aria-disabled="true"]),
3674
+ .axi-titlebar__btns :where(button):is(:disabled, [aria-disabled="true"]) {
3271
3675
  opacity: .5;
3272
3676
  cursor: not-allowed;
3273
3677
  }
3678
+
3679
+ /* ---------- the quiet scroll ---------- */
3680
+ /* A scrollbar down the side of a narrow strip is a channel, not information.
3681
+
3682
+ The language had already decided this and said it in exactly one place, on
3683
+ .axi-palette__list, with the argument written inline: at twelve rows behind
3684
+ a fixed cap the bar reports a fact the row count in the bar has already
3685
+ given. Said only there, it was the language's only word for the decision -
3686
+ and the consumer that needed the same thing for its own strips could not
3687
+ reach it, so it wrote the rule again. Five times, in its own stylesheet, for
3688
+ two rails and a picker slot, twice with a `*` descendant arm because the
3689
+ element that actually scrolls sits one level inside a component it does not
3690
+ control. That is the third instance of the pattern in docs/RULES.md ("A style
3691
+ only reachable through a layer will be re-invented"), and the remedy is the
3692
+ same one: give the decision a name a consumer can spend, and put every
3693
+ spelling of it in ONE rule.
3694
+
3695
+ So: one rule. The class is what a consumer says about its own scroller; the
3696
+ four component parts beside it are the strips the language ships. Two rules
3697
+ that agree today are two rules that disagree after the next edit.
3698
+
3699
+ This is a refusal rather than a utility - it says what NOT to draw - which is
3700
+ why it is here rather than beside each component even though nothing needs
3701
+ the last-in-cascade position to win. One fact about parts living in three
3702
+ different source files has one home, the same as the dead state above.
3703
+
3704
+ Why these four and not everything that scrolls. The strip is the test: a
3705
+ rail is 208px wide and a palette list sits in a small floating panel, so the
3706
+ bar costs a measurable share of the width AND draws a second vertical line
3707
+ beside the object's own edge. A table's horizontal bar is the opposite case
3708
+ and .axi-table__scroll keeps it on purpose (see src/data.css) - there the bar
3709
+ is the only thing telling you a column is off-screen. Nothing here reaches a
3710
+ descendant: `*` would take the table's bar away the moment someone put one
3711
+ in a rail.
3712
+
3713
+ `.axi-rail` and `.axi-rail__nav` both, because either can be the scroller.
3714
+ The rail declares `overflow-y: auto` and is the scroller by default, but a
3715
+ rail with something pinned below its list - a "back" action at the floor -
3716
+ scrolls the nav instead and leaves the rail still. Both are the same strip
3717
+ and the decision cannot depend on which one the consumer chose. */
3718
+ .axi-scroll-quiet,
3719
+ .axi-palette__list,
3720
+ .axi-rail,
3721
+ .axi-rail__nav,
3722
+ .axi-split__nav {
3723
+ scrollbar-width: none;
3724
+ }
3725
+ .axi-scroll-quiet::-webkit-scrollbar,
3726
+ .axi-palette__list::-webkit-scrollbar,
3727
+ .axi-rail::-webkit-scrollbar,
3728
+ .axi-rail__nav::-webkit-scrollbar,
3729
+ .axi-split__nav::-webkit-scrollbar {
3730
+ width: 0;
3731
+ height: 0;
3732
+ }