@axiapps/axi-design 1.43.0 → 1.45.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
@@ -122,6 +122,20 @@
122
122
  --axi-ink-on-fill below, on the other side of the same token. */
123
123
  --axi-ground-deep: var(--axi-ink-line);
124
124
  --axi-rule: #3a4250; /* internal rules, inside an outlined panel */
125
+ /* A gridline is quieter than a rule. A rule divides two things; a grid is
126
+ scaffolding a value is read against, and it has to sit barely above the
127
+ ground it is drawn on or the chart becomes a grid of boxes with numbers
128
+ in them - the failure rule 3 names for tables, arriving in a plot.
129
+
130
+ Derived, not picked, and that is the point of its being a token. The one
131
+ consumer that had drawn a chart grid wrote #262c36 for it: the default
132
+ rule laid over the default ground at 47%, to within one step per channel,
133
+ and a fixed slate that stayed slate under glass and under flat while both
134
+ themes relit their rules. Mixing toward transparent rather than toward
135
+ the ground keeps it right on a panel fill too, and keeps it following
136
+ --axi-rule when a theme restates that - so a theme has nothing to say
137
+ here, and says nothing. */
138
+ --axi-grid: color-mix(in srgb, var(--axi-rule) 47%, transparent);
125
139
  /* --- the well --- */
126
140
  /* A field sunk INTO a surface rather than raised off one: a table picker's
127
141
  list, a bar's trough, a column of cards standing on the page. The language
@@ -347,6 +361,46 @@ a { color: inherit; }
347
361
  }
348
362
  }
349
363
 
364
+ /* The scrollbar is chrome, and chrome is drawn in the language's own ink. A
365
+ browser's default bar is a light capsule on a light channel, which is two
366
+ things this language does not have: a colour at partial opacity over the
367
+ ground (rule 2) and the one radius the system refuses (see --axi-radius).
368
+ The consumer that first themed it had written both anyway - a 999px thumb
369
+ in white at 35% - in its own stylesheet, because the language said nothing
370
+ here, and a decision the language leaves unsaid is one every consumer makes
371
+ differently.
372
+
373
+ So: the thumb is the rule ink, a rectangle, in a transparent channel. The
374
+ channel is transparent rather than the ground for the reason the well's fill
375
+ gives: an opaque page colour inside a translucent pane is a black patch, and
376
+ a bar runs down the inside of panes and wells as often as down the page.
377
+
378
+ Two spellings, one per engine, and neither is a fallback for the other.
379
+ `::-webkit-scrollbar` is the only spelling that controls the SHAPE - the
380
+ standard `scrollbar-color` pair recolours whatever the engine draws, and
381
+ what Chromium draws is a capsule with arrow buttons, the shape this language
382
+ refuses. Worse, Chromium (121+) switches the pseudo-elements off entirely the
383
+ moment either standard property is set, so writing both does not combine
384
+ them: it hands Chromium the capsule and throws the rectangle away. (The
385
+ consumer this replaces had written both, and had been tuning a webkit block
386
+ Chromium stopped reading, for years.) So the standard pair is scoped to the
387
+ engines that have no pseudo-element to disable, which today is Firefox, and
388
+ the pseudo-elements carry the design everywhere else. scrollbar-color
389
+ inherits, so the root says it once; scrollbar-width does not, so it is set
390
+ on everything.
391
+
392
+ The quiet-scroll rule in utilities.css still wins where it applies: those
393
+ selectors carry a class and these carry none, so a strip that has asked for
394
+ no bar gets none, and everything else gets this one. */
395
+ @supports not selector(::-webkit-scrollbar) {
396
+ html { scrollbar-color: var(--axi-rule) transparent; }
397
+ * { scrollbar-width: thin; }
398
+ }
399
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
400
+ ::-webkit-scrollbar-track,
401
+ ::-webkit-scrollbar-corner { background: transparent; }
402
+ ::-webkit-scrollbar-thumb { background: var(--axi-rule); border-radius: 0; }
403
+
350
404
  /* --- primitives.css --- */
351
405
  /* axi design language - primitives.
352
406
  The pieces that recur in every property. Two rules govern the whole file:
@@ -425,13 +479,91 @@ a.axi-panel--tile:hover,
425
479
  button.axi-panel--tile:hover,
426
480
  .axi-panel--tile[role='button']:hover { box-shadow: var(--axi-shadow-control-hover); }
427
481
 
482
+ /* The status cap rule 5 describes, as a thing you can write. A verdict about
483
+ the reading inside a panel, a tile or a stat is said once, across its head,
484
+ in the status ink - directly over the value it judges - and the rest of the
485
+ outline stays the ink line every other edge in the language is drawn in. Not
486
+ down the left: a full-height stripe is the box's frame, and five tiles in a
487
+ row become five coloured frames saying nothing about any one number.
488
+
489
+ It recolours the top edge the element already has rather than adding a bar,
490
+ so it is drawn at the element's own weight by construction - the panel step
491
+ on a panel, the control step on a tile or a stat - which is rule 5's own
492
+ argument against a heavy cap on a light frame. The state is an attribute the
493
+ consumer declares about its data (rule 13), read by the three objects a
494
+ reading lives in; a card's strip is a different claim (which category) and
495
+ keeps its own word.
496
+
497
+ Four values and no fifth: the accent is not a verdict, and a cap in it would
498
+ say "picked" about a thing nobody picked. */
499
+ .axi-panel[data-status="ok"], .axi-stat[data-status="ok"] { border-top-color: var(--axi-ok); }
500
+ .axi-panel[data-status="warn"], .axi-stat[data-status="warn"] { border-top-color: var(--axi-warn); }
501
+ .axi-panel[data-status="danger"], .axi-stat[data-status="danger"] { border-top-color: var(--axi-danger); }
502
+ .axi-panel[data-status="meta"], .axi-stat[data-status="meta"] { border-top-color: var(--axi-meta); }
503
+
504
+ /* The one of these you picked. A grid of panels, tiles or cards where one is
505
+ chosen - a theme picker, a fight picker, a leader you have opened - and the
506
+ surface has to say which.
507
+
508
+ Not the accent fill a rail item takes. A rail item is a word, and a word can
509
+ be printed on the accent; a panel carries content, and an accent fill drowns
510
+ it. The answer is the third weight .axi-rail__nav--quiet already found for
511
+ the same reason: the surface rises and the accent arrives on the edge. A
512
+ whole edge here rather than the leading one, because a grid is read in two
513
+ directions and has no leading side to light.
514
+
515
+ Keyed off what the element already says, the way .axi-panel--tile keys its
516
+ press off being an <a> or a <button> rather than off a second class. The
517
+ language has one spelling per selection semantics, and this rule takes all
518
+ of them rather than inventing a fourth: `aria-current` for a place you
519
+ navigated to (a rail item, a tab, a table row), `aria-pressed` for a toggle
520
+ (a pill), `aria-selected` for an option in a listbox (a picker option). A
521
+ consumer that marks a card by appending a border utility has told assistive
522
+ tech nothing at all, and that is the defect this replaces rather than the
523
+ missing fill - ten sites in one consumer, six different bases, three
524
+ invented extra marks, and not one of them announced.
525
+
526
+ The fourth arm is a <label> wrapping its own radio, which is the correct
527
+ markup for a picker made of panels: the input carries the state, so the
528
+ label has no attribute to set and copying the state onto one would be a
529
+ second source of truth that can disagree. This is the language's only
530
+ :has(), and the child combinator is the whole point - `> input:checked`
531
+ means this panel IS the control, where a descendant match would light a
532
+ panel up for any checkbox buried in its content.
533
+
534
+ Both spellings on one rule, per "a style only reachable through a layer will
535
+ be re-invented": .axi-card is a shell and .axi-panel a primitive, and a
536
+ consumer marking one of them must not have to find out which file its
537
+ selected state was written in. The fill is the `background` shorthand and
538
+ not the -paint companion, because --axi-surface-raised is a surface token
539
+ and a theme is allowed to make a surface a gradient. */
540
+ :is(.axi-card, .axi-panel):is(
541
+ [aria-current],
542
+ [aria-pressed='true'],
543
+ [aria-selected='true'],
544
+ :has(> input:checked)
545
+ ) {
546
+ background: var(--axi-surface-raised);
547
+ border-color: var(--axi-accent);
548
+ }
549
+
428
550
  /* ---------- well ---------- */
429
551
  /* The panel's inverse: a field sunk into the surface around it rather than
430
552
  raised off it. No block, because a recess casts nothing, and the rule rather
431
553
  than the ink for its edge - see --axi-well-fill in src/tokens.css for why
432
554
  both of those follow from the same fact. Reach for this wherever a second
433
555
  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. */
556
+ of cards standing on the page, a trough a bar fills.
557
+
558
+ .axi-split__nav is the first of those three named as a part rather than left
559
+ to the consumer to remember, and it is in THIS rule rather than restating the
560
+ fill next to the split pane. Two rules that agree today are two rules that
561
+ disagree after the next edit - see "A style only reachable through a layer
562
+ will be re-invented" in docs/RULES.md, which is the same argument arriving
563
+ from the other direction. The split pane's slot cannot be a well by
564
+ convention: a consumer who forgets the second class gets a picker with no
565
+ recess, and the language has no way to notice. */
566
+ .axi-split__nav,
435
567
  .axi-well {
436
568
  background: var(--axi-well-fill);
437
569
  border: var(--axi-border-control) solid var(--axi-well-line);
@@ -530,6 +662,43 @@ button.axi-panel--tile:hover,
530
662
  icon-only button has no label to pad around, so the 20px sides meant for
531
663
  one produced a wide rectangle around a single glyph - visible in this
532
664
  language's own gallery, in the example titled "Alone in a button". */
665
+ /* Icon over label rather than beside it. Not a style choice - it is the only
666
+ thing that makes a bar of equal actions fit a phone. Measured on the
667
+ consumer that needed it: four icon+label actions side by side want 387px of
668
+ the 337px available at 393px wide, and every label is one unbreakable word,
669
+ so min-content equals max-content and flex-shrink has nothing to give. The
670
+ last action simply runs off the screen. Stacked, the same four come to
671
+ ~291px.
672
+
673
+ min-width: 0 because that failure has a second half. A flex item's default
674
+ min-width is its content, so a button in a row refuses to shrink below its
675
+ own label even when the row is told to divide the space - which is the same
676
+ trap .axi-tabs__tab documents for its label and .axi-split__body for its
677
+ track.
678
+
679
+ The label is addressed as an element because a stacked button has two
680
+ children and only one of them is words; naming a part would make every
681
+ consumer add a class to say what the span already says. :where() so it
682
+ spends no specificity - the ink layer and any modifier still out-rank it,
683
+ which is the contract src/utilities.css is owed. */
684
+ .axi-btn--stack {
685
+ flex-direction: column;
686
+ gap: 5px;
687
+ min-width: 0;
688
+ }
689
+ .axi-btn--stack > :where(span) {
690
+ /* Both, and the pair is the point: max-width caps it against the button,
691
+ min-width: 0 is what lets it shrink at all. A flex item's default
692
+ min-width is its content, so max-width alone leaves the ellipsis
693
+ unreachable - the label overflows the cap instead of truncating inside
694
+ it. tests/tokens.test.mjs pins the pair for every truncating rule in the
695
+ language, and it caught this one. */
696
+ max-width: 100%;
697
+ min-width: 0;
698
+ overflow: hidden;
699
+ text-overflow: ellipsis;
700
+ white-space: nowrap;
701
+ }
533
702
  .axi-btn--sm { --axi-btn-pad: 8px 13px; --axi-btn-size: 12px; }
534
703
  .axi-btn--xs { --axi-btn-pad: 5px 9px; --axi-btn-size: 11px; }
535
704
  .axi-btn--icon { --axi-btn-pad: 7px; }
@@ -828,7 +997,7 @@ button.axi-panel--tile:hover,
828
997
  says `axi-link axi-ink-dim` must stay dim under the cursor, and the plain
829
998
  `:hover` form is what made the danger button read white under one. */
830
999
  .axi-link,
831
- .axi-prose a {
1000
+ .axi-prose :where(a) {
832
1001
  padding: 0;
833
1002
  background: none;
834
1003
  border: 0;
@@ -841,7 +1010,7 @@ button.axi-panel--tile:hover,
841
1010
  cursor: pointer;
842
1011
  }
843
1012
  .axi-link:where(:hover),
844
- .axi-prose a:where(:hover) { color: var(--axi-text); }
1013
+ .axi-prose :where(a:hover) { color: var(--axi-text); }
845
1014
 
846
1015
  /* ---------- quiet action ---------- */
847
1016
  /* A control that is only its label. Not a button - it draws no box, takes no
@@ -967,8 +1136,8 @@ button.axi-panel--tile:hover,
967
1136
  disclosure that drops back flat the moment the pointer moves into the list
968
1137
  it opened severs the two: the lift is what says this box and that popover
969
1138
  are one control. */
970
- .axi-select:hover,
971
- .axi-picker__btn:hover,
1139
+ .axi-select:where(:hover),
1140
+ .axi-picker__btn:where(:hover),
972
1141
  .axi-picker__btn[aria-expanded='true'] {
973
1142
  color: var(--axi-text);
974
1143
  box-shadow: var(--axi-shadow-control);
@@ -995,7 +1164,7 @@ button.axi-panel--tile:hover,
995
1164
  backdrop-filter: var(--axi-surface-filter);
996
1165
  box-shadow: var(--axi-shadow-panel);
997
1166
  }
998
- .axi-select option {
1167
+ .axi-select :where(option) {
999
1168
  display: flex; align-items: center; gap: 9px;
1000
1169
  padding: 8px 9px;
1001
1170
  border-radius: var(--axi-radius-sm);
@@ -1010,7 +1179,7 @@ button.axi-panel--tile:hover,
1010
1179
  level's fill - and this popover is filled with --axi-surface-raised, which
1011
1180
  a theme may take translucent, so an opaque row here is the black patch
1012
1181
  --axi-well-fill exists to prevent. Same for the picker's rows below. */
1013
- .axi-select option:hover, .axi-select option:focus {
1182
+ .axi-select :where(option:hover, option:focus) {
1014
1183
  background: var(--axi-well-fill); color: var(--axi-text);
1015
1184
  }
1016
1185
  /* The page-wide focus ring sits 2px outside its element; inside a picker
@@ -1109,7 +1278,7 @@ button.axi-panel--tile:hover,
1109
1278
  color: transparent;
1110
1279
  font-weight: 900;
1111
1280
  }
1112
- .axi-picker__opt:hover, .axi-picker__opt:focus {
1281
+ .axi-picker__opt:where(:hover, :focus) {
1113
1282
  background: var(--axi-well-fill); color: var(--axi-text);
1114
1283
  }
1115
1284
  /* The page-wide focus ring sits 2px outside its element; inside a popover
@@ -1296,6 +1465,68 @@ textarea.axi-input {
1296
1465
  /* A horizontal run that wraps rather than overflowing. */
1297
1466
  .axi-row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--axi-row-gap, 10px); }
1298
1467
 
1468
+ /* ---------- split pane ---------- */
1469
+ /* A picker on the left choosing what the surface on the right shows. Thirteen
1470
+ sections of one consumer are this shape, and a fourteenth had hand-built it
1471
+ inline because there was no name for it.
1472
+
1473
+ It is two objects on one plane and not two panels, which is the whole reason
1474
+ it needs naming: a picker list beside a table is the first case --axi-well's
1475
+ own comment names, and the thing the list picks is the raised counterpart to
1476
+ that recess. Drawn as two panels it read flat - a hairline parting one field,
1477
+ with the list's scrollbar carving a groove down the middle of it - because
1478
+ nothing in it was an object.
1479
+
1480
+ The body carries the CONTROL step, not the panel's. A split pane lives
1481
+ inside a panel that has already paid a 6px block, and rule 8's counterpart
1482
+ settles what a second one nested in it reads as: two planes arguing. The
1483
+ consumer that derived this shape by hand got the outline and left out the
1484
+ block entirely, which is a rule 3 failure in the other direction - a
1485
+ boundary drawn around content that is standing on the surface behind it.
1486
+ Outlined and blocked, at the weight that says "the contents of a box".
1487
+
1488
+ The radius follows the form step and not the footprint. The body is
1489
+ panel-sized in area and control-weight in form, and .axi-panel--tile already
1490
+ settled that argument the same way.
1491
+
1492
+ `minmax(0, 1fr)` rather than `1fr`: a table in a `1fr` track cannot shrink
1493
+ below its own content, and every cell in .axi-table is `nowrap`, so the
1494
+ column blows out and takes the pane's width with it. `1fr` is
1495
+ `minmax(auto, 1fr)` and `auto` is a content floor.
1496
+
1497
+ No breakpoint of its own. Under 640px the two stack, which is layout.css's
1498
+ one breakpoint and deliberately still its only one - .axi-table--fixed
1499
+ already makes a 360px pane work, so a pane narrow enough to need a second
1500
+ breakpoint is one --fixed was written for. */
1501
+ .axi-split {
1502
+ display: grid;
1503
+ grid-template-columns: var(--axi-split-nav-w, 280px) minmax(0, 1fr);
1504
+ gap: var(--axi-split-gap, 10px);
1505
+ /* A split pane is routinely the flex child that fills a section's height,
1506
+ and a grid that cannot shrink below its content pushes the section past
1507
+ its own cap - the same reason .axi-palette__list says it. */
1508
+ min-height: 0;
1509
+ }
1510
+ /* The picker is a well, declared as one in the same rule as .axi-well in
1511
+ src/primitives.css rather than restated here. This part only says what the
1512
+ slot adds: it scrolls, and it may not out-grow the row it sits in. Its
1513
+ scrollbar is hidden by the language's quiet-scroll rule in
1514
+ src/utilities.css, which is where that decision lives for every strip. */
1515
+ .axi-split__nav { overflow-y: auto; min-height: 0; }
1516
+ .axi-split__body {
1517
+ /* Paired with the track's minmax(0, 1fr): the track stops the COLUMN
1518
+ growing, this stops the ITEM, whose min-width is `auto` by default and is
1519
+ what an ellipsis inside it is actually fighting. */
1520
+ min-width: 0;
1521
+ min-height: 0;
1522
+ overflow: hidden;
1523
+ background: var(--axi-surface);
1524
+ backdrop-filter: var(--axi-surface-filter);
1525
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1526
+ border-radius: var(--axi-radius-sm);
1527
+ box-shadow: var(--axi-shadow-control);
1528
+ }
1529
+
1299
1530
  @media (max-width: 640px) {
1300
1531
  /* The fallback must match the resting rule's (var(--axi-gutter), 18px) -
1301
1532
  README.md documents --axi-page-pad's fallback as --axi-gutter, and a
@@ -1303,6 +1534,12 @@ textarea.axi-input {
1303
1534
  mobile, which is the one viewport where the gutter matters most. */
1304
1535
  .axi-page { padding-inline: var(--axi-page-pad, var(--axi-gutter)); }
1305
1536
  .axi-grid { grid-template-columns: 1fr; }
1537
+ /* The picker stops being a column beside the thing it picks and becomes the
1538
+ row above it. It keeps a height cap - a picker that grows to twenty rows
1539
+ pushes the result it picked off the screen, which is the one thing the
1540
+ stacked form must not do. */
1541
+ .axi-split { grid-template-columns: 1fr; }
1542
+ .axi-split__nav { max-height: var(--axi-split-nav-h, 200px); }
1306
1543
  }
1307
1544
 
1308
1545
  /* --- shells.css --- */
@@ -1311,14 +1548,23 @@ textarea.axi-input {
1311
1548
  redefining them, so a change to .axi-panel moves the notice, the toolbar,
1312
1549
  the popover and the card together. */
1313
1550
 
1314
- /* The small uppercase heading that labels a panel's contents. */
1551
+ /* The small uppercase heading that labels a panel's contents.
1552
+
1553
+ The gap under it is a knob because the eyebrow heads more than one size of
1554
+ thing. 14px is the figure for a panel at its resting padding; a tile is one
1555
+ form step down and its padding is one density step down with it, so the
1556
+ eyebrow inside one reads the tighter figure in the rule below, the same way
1557
+ .axi-panel--tile reads --axi-panel-pad at a tighter fallback of its own. An
1558
+ eyebrow sitting in a row - a sheet's head, a toolbar - wants no gap at all,
1559
+ and says so with the knob rather than with an inline margin. */
1315
1560
  .axi-eyebrow {
1316
- margin: 0 0 14px;
1561
+ margin: 0 0 var(--axi-eyebrow-gap, 14px);
1317
1562
  font: var(--axi-t-eyebrow);
1318
1563
  letter-spacing: var(--axi-ls-eyebrow);
1319
1564
  text-transform: uppercase;
1320
1565
  color: var(--axi-text-faint);
1321
1566
  }
1567
+ .axi-panel--tile .axi-eyebrow { margin-bottom: var(--axi-eyebrow-gap, 6px); }
1322
1568
 
1323
1569
  /* ---------- masthead ---------- */
1324
1570
  /* Opaque, because content scrolls under it. Which is also why it paints the
@@ -1403,9 +1649,35 @@ textarea.axi-input {
1403
1649
  it shrinks an item - so one tab too many sent the last item in source order
1404
1650
  to a second row instead of taking width off the widest one. The strip is the
1405
1651
  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 {
1652
+ .axi-mast .axi-tabs { margin-left: auto; }
1653
+ /* A strip with more tabs than room scrolls rather than losing them.
1654
+
1655
+ .axi-tabs is `display: flex` with no wrap, so a strip too wide for its
1656
+ container overflows - and outside a scroll container the tabs past the edge
1657
+ are simply unreachable. The language already believed this twice and could
1658
+ say it in neither place a consumer could use: once here, behind .axi-mast,
1659
+ and once at the 640px breakpoint below, where EVERY strip scrolls. A
1660
+ standalone strip above that breakpoint with one tab too many had nowhere to
1661
+ look, and the consumer that hit it wrote `overflow-x: auto` on its own
1662
+ wrapper - the same re-invention docs/RULES.md describes, arriving a fourth
1663
+ time.
1664
+
1665
+ A modifier rather than the base rule, and this is the part worth arguing.
1666
+ `overflow-x: auto` computes `overflow-y` from `visible` to `auto`, so a
1667
+ strip that scrolls is also a strip that clips vertically - a focus ring, a
1668
+ badge hanging off a tab, a popover anchored to one. Every strip paying that
1669
+ to fix the strips that are too long is the wrong trade; the strips that are
1670
+ too long say so.
1671
+
1672
+ The bar is NOT hidden here, and the quiet-scroll rule in src/utilities.css
1673
+ deliberately does not name this. That rule is about a bar drawn down the
1674
+ side of a narrow strip, where the object's own edge is already a vertical
1675
+ line and a count elsewhere already says how many rows there are. A tab strip
1676
+ that scrolls horizontally has no second signal: the bar is the only thing
1677
+ saying there are more tabs. Same reason .axi-table__scroll keeps its. */
1678
+ .axi-tabs--scroll,
1679
+ .axi-mast .axi-tabs { flex-shrink: 1; min-width: 0; overflow-x: auto; }
1680
+ .axi-tabs :where(a, button) {
1409
1681
  padding: 8px 16px;
1410
1682
  font: 800 13.5px/1 var(--axi-sans);
1411
1683
  color: var(--axi-text-dim); text-decoration: none;
@@ -1414,8 +1686,7 @@ textarea.axi-input {
1414
1686
  border: var(--axi-border-control) solid transparent;
1415
1687
  border-radius: var(--axi-radius-sm);
1416
1688
  }
1417
- .axi-tabs a:hover,
1418
- .axi-tabs button:hover { color: var(--axi-text); border-color: var(--axi-rule); }
1689
+ .axi-tabs :where(a:hover, button:hover) { color: var(--axi-text); border-color: var(--axi-rule); }
1419
1690
  /* The current tab is filled and blocked - the same treatment a pressed pill
1420
1691
  gets, because it is the same idea: this one is on. */
1421
1692
  .axi-tabs a[aria-current="page"],
@@ -1447,13 +1718,13 @@ textarea.axi-input {
1447
1718
  text-overflow: ellipsis;
1448
1719
  white-space: nowrap;
1449
1720
  }
1450
- .axi-tabs .axi-tabs__close {
1721
+ .axi-tabs :where(.axi-tabs__close) {
1451
1722
  padding: 0 6px;
1452
1723
  font: var(--axi-t-micro);
1453
1724
  color: var(--axi-text-faint);
1454
1725
  border-color: transparent;
1455
1726
  }
1456
- .axi-tabs .axi-tabs__close:hover { color: var(--axi-text); border-color: transparent; }
1727
+ .axi-tabs :where(.axi-tabs__close:hover) { color: var(--axi-text); border-color: transparent; }
1457
1728
 
1458
1729
  /* ---------- rail ---------- */
1459
1730
  /* Vertical navigation, for a set too large for a tab strip to hold. Same
@@ -1527,7 +1798,7 @@ textarea.axi-input {
1527
1798
  border-radius: var(--axi-radius-sm);
1528
1799
  cursor: pointer;
1529
1800
  }
1530
- .axi-rail__item:hover { color: var(--axi-text); background: var(--axi-surface-raised); }
1801
+ .axi-rail__item:where(:hover) { color: var(--axi-text); background: var(--axi-surface-raised); }
1531
1802
  /* The same treatment a pressed pill and a current tab get. Only ever one of
1532
1803
  these on screen. Any aria-current value, because the right one depends on
1533
1804
  what the rail navigates - `page` for a view, `location` for a place within
@@ -1590,7 +1861,7 @@ textarea.axi-input {
1590
1861
  border-radius: var(--axi-radius-sm);
1591
1862
  cursor: pointer;
1592
1863
  }
1593
- .axi-rail__subitem:hover { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
1864
+ .axi-rail__subitem:where(:hover) { color: var(--axi-text-dim); background: var(--axi-surface-raised); }
1594
1865
  .axi-rail__subitem[aria-current] { color: var(--axi-text); }
1595
1866
  /* An annotation on an item, not a measurement of it, so rule 5 draws it
1596
1867
  outlined rather than filled. */
@@ -1738,13 +2009,13 @@ textarea.axi-input {
1738
2009
  box-shadow: var(--axi-shadow-panel);
1739
2010
  }
1740
2011
  .axi-menu__pop[hidden] { display: none; }
1741
- .axi-menu__pop label {
2012
+ .axi-menu__pop :where(label) {
1742
2013
  display: flex; gap: 9px; align-items: flex-start;
1743
2014
  padding: 7px 8px; border-radius: var(--axi-radius-sm);
1744
2015
  font-size: 13px; font-weight: 600; color: var(--axi-text-dim); cursor: pointer;
1745
2016
  }
1746
2017
  /* 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); }
2018
+ .axi-menu__pop :where(label:hover) { background: var(--axi-well-fill); color: var(--axi-text); }
1748
2019
  .axi-menu__pop input { margin: 3px 0 0; }
1749
2020
 
1750
2021
  /* ---------- command palette ---------- */
@@ -1805,14 +2076,16 @@ textarea.axi-input {
1805
2076
  }
1806
2077
  /* min-height: 0 because the panel is a flex column and a list that cannot
1807
2078
  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. */
2079
+ scrollbar is hidden, but not here: that decision is the language's and not
2080
+ this component's, and it now lives once, in src/utilities.css under "the
2081
+ quiet scroll", with this selector in the same rule as every other strip that
2082
+ makes it. The argument that produced it was written here first - at twelve
2083
+ rows behind a fixed cap the bar is a channel carved down the side of a small
2084
+ panel to report a fact the row count in the bar has already given - and it is
2085
+ preserved in full at the rule. */
1811
2086
  .axi-palette__list {
1812
2087
  flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0;
1813
- scrollbar-width: none;
1814
2088
  }
1815
- .axi-palette__list::-webkit-scrollbar { width: 0; height: 0; }
1816
2089
  /* One .axi-palette__section per group of rows, each opening with a
1817
2090
  .axi-palette__group heading. The section is a named part rather than left to
1818
2091
  the consumer's wrapper so the rule between groups can be stated as what it
@@ -1834,7 +2107,7 @@ textarea.axi-input {
1834
2107
  color: var(--axi-text-dim);
1835
2108
  font: var(--axi-t-small); text-align: left; cursor: pointer;
1836
2109
  }
1837
- .axi-palette__row:hover { background: var(--axi-surface-raised); color: var(--axi-text); }
2110
+ .axi-palette__row:where(:hover) { background: var(--axi-surface-raised); color: var(--axi-text); }
1838
2111
  .axi-palette__row[data-active] { background: var(--axi-accent); color: var(--axi-accent-ink); }
1839
2112
  /* The row's own ink is set above, but a row carries a glyph and a second line
1840
2113
  of text that were coloured on their own; on the filled row they all become
@@ -1866,7 +2139,7 @@ textarea.axi-input {
1866
2139
  cursor: pointer;
1867
2140
  transition: transform .1s, box-shadow .1s, color .1s;
1868
2141
  }
1869
- .axi-palette__trigger:hover {
2142
+ .axi-palette__trigger:where(:hover) {
1870
2143
  color: var(--axi-text);
1871
2144
  box-shadow: var(--axi-shadow-control);
1872
2145
  transform: translate(-2px, -2px);
@@ -2013,7 +2286,7 @@ textarea.axi-input {
2013
2286
  color: var(--axi-text-faint);
2014
2287
  }
2015
2288
  .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); }
2289
+ .axi-card:where(:hover) :where(.axi-card__go) { color: var(--axi-text); }
2017
2290
 
2018
2291
  /* ---------- drawer ---------- */
2019
2292
  /* A scrim's layer is not a property of the scrim. It is "directly below the
@@ -2086,7 +2359,7 @@ textarea.axi-input {
2086
2359
  font-size: 15px; font-weight: 900; cursor: pointer;
2087
2360
  transition: transform .1s, box-shadow .1s;
2088
2361
  }
2089
- .axi-drawer__close:hover {
2362
+ .axi-drawer__close:where(:hover) {
2090
2363
  background: var(--axi-accent); color: var(--axi-accent-ink);
2091
2364
  box-shadow: var(--axi-shadow-control);
2092
2365
  transform: translate(-2px, -2px);
@@ -2228,7 +2501,7 @@ textarea.axi-input {
2228
2501
  -webkit-app-region: drag;
2229
2502
  }
2230
2503
  .axi-titlebar__btns { margin-left: auto; display: flex; height: 100%; -webkit-app-region: no-drag; }
2231
- .axi-titlebar__btns button {
2504
+ .axi-titlebar__btns :where(button) {
2232
2505
  width: 44px;
2233
2506
  border: 0;
2234
2507
  background: transparent;
@@ -2239,10 +2512,10 @@ textarea.axi-input {
2239
2512
  /* No lift here. A window button is chrome, not content: it has nothing to
2240
2513
  lift off, and 44px of movement in the corner of every screen is exactly
2241
2514
  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); }
2515
+ .axi-titlebar__btns :where(button:hover) { background: var(--axi-surface); color: var(--axi-text); }
2243
2516
  /* Close is the one destructive control in the chrome, and the one place a
2244
2517
  status ink belongs in a titlebar. */
2245
- .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
2518
+ .axi-titlebar__btns :where(button:last-child:hover) { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
2246
2519
  /* The scrolling region under the chrome. */
2247
2520
  .axi-window__body { flex: 1; overflow-y: auto; }
2248
2521
 
@@ -2261,8 +2534,8 @@ textarea.axi-input {
2261
2534
  flex-wrap: wrap;
2262
2535
  font: var(--axi-t-small);
2263
2536
  }
2264
- .axi-crumbs a { color: var(--axi-text-dim); text-decoration: none; }
2265
- .axi-crumbs a:hover { color: var(--axi-accent); }
2537
+ .axi-crumbs :where(a) { color: var(--axi-text-dim); text-decoration: none; }
2538
+ .axi-crumbs :where(a:hover) { color: var(--axi-accent); }
2266
2539
  /* 7px, matching the prose bullet (src/prose.css) exactly, with the same
2267
2540
  --axi-border-hairline outline: rule 7 calls the motif "a 45deg-rotated
2268
2541
  outlined square", and a bare fill with no border does not match it. The
@@ -2296,7 +2569,7 @@ textarea.axi-input {
2296
2569
  border: var(--axi-border-control) solid var(--axi-ink-line);
2297
2570
  border-radius: var(--axi-radius-sm);
2298
2571
  }
2299
- .axi-pages__n:hover { color: var(--axi-text); }
2572
+ .axi-pages__n:where(:hover) { color: var(--axi-text); }
2300
2573
  .axi-pages__n[aria-current="page"] {
2301
2574
  background: var(--axi-accent);
2302
2575
  color: var(--axi-accent-ink);
@@ -2309,7 +2582,12 @@ textarea.axi-input {
2309
2582
  @media (max-width: 640px) {
2310
2583
  .axi-mast__in { min-height: 0; padding: 10px 16px; gap: 12px; }
2311
2584
  .axi-mast .axi-tabs { margin-left: 0; }
2312
- .axi-tabs { width: 100%; overflow-x: auto; }
2585
+ /* Every strip scrolls on a phone, whether or not it asked. This cannot join
2586
+ the shared rule above - a media-query rule and an unconditional one are
2587
+ different rules by construction - so the values are pinned against it by
2588
+ test instead: the arm may declare nothing the modifier does not, and
2589
+ nothing at a different value. */
2590
+ .axi-tabs { width: 100%; flex-shrink: 1; min-width: 0; overflow-x: auto; }
2313
2591
  /* The offset blocks eat horizontal room a phone does not have, so the panel
2314
2592
  step drops to the control step here rather than to an invented number.
2315
2593
  The card's hover has to come down with it - left alone it kept deepening
@@ -2601,7 +2879,17 @@ textarea.axi-input {
2601
2879
  resting cell rule (`.axi-table :where(td, tbody th)`, also one class) and
2602
2880
  wins on order, while an ink class in utilities.css - one class, later file -
2603
2881
  wins over both. */
2604
- .axi-table :where(tbody tr:hover) :where(td, th) {
2882
+ /* A DATA FILL SURVIVES A ROW STATE, and the `:not()` sits inside `:where()`
2883
+ so saying so costs no specificity. A matrix cell's band IS its value (rule 9,
2884
+ "the matrix is the bound on not at all") - washing it on the way past the row
2885
+ you want deletes the reading. The row state is not weakened by this: it is
2886
+ drawn by the leading edge below, and by every cell that has nothing to say.
2887
+ Which is the edge-not-fill answer the selection rule below reaches on its own
2888
+ grounds, arriving here a second time.
2889
+
2890
+ Nothing changes for a table with no `data-heat` in it, which is every table
2891
+ in this language but one. */
2892
+ .axi-table :where(tbody tr:hover) :where(td:not([data-heat]), th:not([data-heat])) {
2605
2893
  background: var(--axi-surface-raised);
2606
2894
  background-attachment: fixed;
2607
2895
  color: var(--axi-text);
@@ -2636,7 +2924,7 @@ textarea.axi-input {
2636
2924
  .axi-table :where(tbody tr) > :where(td, th):first-child {
2637
2925
  border-inline-start: var(--axi-border-control) solid transparent;
2638
2926
  }
2639
- .axi-table tbody tr[aria-current] :is(td, th) {
2927
+ .axi-table tbody tr[aria-current] :is(td, th):where(:not([data-heat])) {
2640
2928
  background: var(--axi-surface-raised);
2641
2929
  background-attachment: fixed;
2642
2930
  color: var(--axi-text);
@@ -2797,6 +3085,115 @@ textarea.axi-input {
2797
3085
  }
2798
3086
  .axi-table th:first-child .axi-table__sort { justify-content: flex-start; }
2799
3087
 
3088
+ /* ---------- matrix ---------- */
3089
+ /* Two categorical axes and a quantity where they cross: forty players down the
3090
+ side, sixty five-second buckets across, a count in each cell. Rule 9's
3091
+ "the matrix is the bound on not at all" is the whole argument for this
3092
+ existing at all, and the short form is: the plane is spent, so there is no
3093
+ third dimension left to give the quantity a LENGTH, and both axes are
3094
+ already sorted by something the reader needs so neither can be re-sorted by
3095
+ the value. Intensity is what is left, and it is admissible here because the
3096
+ cell also prints its number - the digit is the legible copy rule 9 demands,
3097
+ and the band is only what lets the eye find the shape without reading two
3098
+ thousand figures one at a time. A matrix cell with nothing written in it is
3099
+ a heatmap and rule 9 refuses it.
3100
+
3101
+ This is a MODIFIER and not a component, and that is the finding rather than
3102
+ a convenience. Everything structural a matrix field needs, this table
3103
+ already had: --sticky for the ruler that stays, --pinned for the names that
3104
+ stay, --dense for sixty columns' padding, --fixed plus a <colgroup> for
3105
+ proportional cells, and .axi-table__scroll for the frame they move inside.
3106
+ The consumer measured against here had hand-written all five, and had got
3107
+ --sticky wrong in exactly the way that modifier's comment warns about: its
3108
+ ruler was --axi-surface-raised, so under glass the rows slid through it.
3109
+ The only thing genuinely missing was the quantity.
3110
+
3111
+ What the modifier changes is the CELL, which stops being a number in a
3112
+ column and becomes a patch in a field: centred rather than right-aligned,
3113
+ because a band is read by position and not by its digits lining up, and
3114
+ unpadded because the padding is the field. The name column keeps the base
3115
+ table's left alignment and its gutter - it is still a name. */
3116
+ .axi-table--matrix { border-collapse: separate; border-spacing: 0; }
3117
+ .axi-table--matrix thead th { padding: 0 0 6px; text-align: center; }
3118
+ .axi-table--matrix :is(td, tbody th) {
3119
+ padding: 0;
3120
+ text-align: center;
3121
+ height: var(--axi-matrix-cell, 24px);
3122
+ }
3123
+ /* `min-width` and not `width`: under the default auto layout this is the floor
3124
+ a one-digit column cannot fall below, and under --fixed a <colgroup> takes
3125
+ over entirely (fixed layout ignores both min and max - see --fixed). */
3126
+ .axi-table--matrix :is(td, tbody th):not(:first-child) {
3127
+ min-width: var(--axi-matrix-cell, 24px);
3128
+ }
3129
+ .axi-table--matrix :is(td, tbody th):first-child {
3130
+ text-align: left;
3131
+ padding: 0 10px 0 0;
3132
+ }
3133
+ /* The four bands. Each is a color-mix() of the accent into the surface's flat
3134
+ companion, which makes every one of them a computed OPAQUE colour - the
3135
+ accent at 18% alpha over the field would be rule 2's faded ink, and it also
3136
+ fails on its own terms, because the cells a reader is scanning for are the
3137
+ quiet ones and an alpha ramp is where those disappear.
3138
+
3139
+ --axi-surface-paint and not --axi-surface, and this is the trap: a surface
3140
+ token is allowed to hold a gradient (rule 1's one relief, which is how a
3141
+ glass theme exists), color-mix() takes colours and nothing else, and an
3142
+ invalid color-mix() is dropped at computed-value time. Spell it --axi-surface
3143
+ and the bands do not fade - they vanish, silently, in whichever theme paints
3144
+ a gradient. That is measured, not hypothetical: it is why
3145
+ --axi-surface-paint exists (see tokens.css).
3146
+
3147
+ Four steps and not a continuous ramp, because a band is also where the digit
3148
+ has to change ink, and a step is the only thing a flip can happen ON. The
3149
+ top two carry --axi-accent-ink for the same reason .axi-btn--primary does:
3150
+ past roughly half strength the field is the accent and the text on it is the
3151
+ accent's companion, not the text ramp. */
3152
+ .axi-table--matrix :is(td, tbody th)[data-heat='1'] {
3153
+ background-color: color-mix(in srgb, var(--axi-accent) 18%, var(--axi-surface-paint));
3154
+ }
3155
+ .axi-table--matrix :is(td, tbody th)[data-heat='2'] {
3156
+ background-color: color-mix(in srgb, var(--axi-accent) 42%, var(--axi-surface-paint));
3157
+ }
3158
+ .axi-table--matrix :is(td, tbody th)[data-heat='3'] {
3159
+ background-color: color-mix(in srgb, var(--axi-accent) 70%, var(--axi-surface-paint));
3160
+ color: var(--axi-accent-ink);
3161
+ }
3162
+ .axi-table--matrix :is(td, tbody th)[data-heat='4'] {
3163
+ background-color: color-mix(in srgb, var(--axi-accent) 100%, var(--axi-surface-paint));
3164
+ color: var(--axi-accent-ink);
3165
+ }
3166
+ /* A matrix whose columns are a TIMELINE rather than a list of categories, which
3167
+ changes two things and only two.
3168
+
3169
+ The column labels move left. A label over a category names the column and
3170
+ belongs centred over it; a label on a ruler names a MOMENT, and centring it
3171
+ puts the text half a cell to the right of the instant it is pointing at.
3172
+
3173
+ And the division gets a line. `[data-tick]` marks the columns that carry a
3174
+ label - every thirtieth second, not every bucket, because at a five-second
3175
+ resolution a five-minute fight is sixty columns and a timestamp over each is
3176
+ unreadable at the width a cell allows. The line is the RULE, at the hairline
3177
+ step: it is the same line that parts the rows, continued down the field,
3178
+ which is the whole reason it is not drawn on every column. Sixty ruled
3179
+ columns is a spreadsheet; the bands are meant to be the figure. */
3180
+ .axi-table--ruler thead th { text-align: left; }
3181
+ .axi-table--ruler :is(thead th, td, tbody th)[data-tick] {
3182
+ border-left: var(--axi-border-hairline) solid var(--axi-rule);
3183
+ }
3184
+ /* A change of category down the rows - a subgroup, a team, a date. Heavier than
3185
+ the hairline that parts two rows of one group, and heavier in INK rather than
3186
+ in weight: the same line, drawn darker. Going up a form step instead would
3187
+ put a control-weight line inside running content, which is rule 8's grid of
3188
+ boxes, and the boundary would then compete with the head's own lid.
3189
+
3190
+ An attribute and not a class, per rule 13, and on the row because that is
3191
+ what starts: the consumer compares one row's category with the previous
3192
+ row's, which is a fact only it can know. */
3193
+ .axi-table tbody tr[data-group-start] > :is(td, th) {
3194
+ border-top: var(--axi-border-hairline) solid var(--axi-ink-line);
3195
+ }
3196
+
2800
3197
  /* ---------- meter ---------- */
2801
3198
  /* Rule 9: a proportion is a length. The track is the ground, the fill is the
2802
3199
  value, and the fill is one ink at full strength - a tinted or faded bar is
@@ -2846,6 +3243,61 @@ textarea.axi-input {
2846
3243
  font-variant-numeric: tabular-nums;
2847
3244
  }
2848
3245
 
3246
+ /* ---------- readout ---------- */
3247
+ /* A short run of labelled readings: logs seen, uploaded, failed; the session's
3248
+ start and its length; a setting and the switch that sets it. The shape of
3249
+ every status card in a dashboard's side column, and the one the consumer
3250
+ that had four of them built by hand each time.
3251
+
3252
+ Rule 8 settles how it is drawn. The eye runs down the value column - that is
3253
+ the whole point of stacking the readings - so it is a table's interior:
3254
+ rows parted by the rule at the hairline weight, no outline and no block on
3255
+ any row, nothing raised inside the panel that is already the raised thing.
3256
+ It is not a table element because it is two cells wide, has no head, and
3257
+ the reading in the second cell is as often a control as a figure; a <dl> is
3258
+ what the document structure calls for and the classes sit on it.
3259
+
3260
+ The key is the meter list's name and the value is the meter list's value,
3261
+ restated here rather than aliased because the two lists are laid out
3262
+ differently and rule-8's test - is there a column? - is the same for both.
3263
+
3264
+ Row padding is a knob at two fallbacks, like the eyebrow's gap: 7px at
3265
+ panel density, and 3px inside a tile, where a hairline already holds the
3266
+ rows apart and 3px is still a clear gap at this type size. */
3267
+ .axi-readout {
3268
+ display: flex;
3269
+ flex-direction: column;
3270
+ margin: 0;
3271
+ }
3272
+ .axi-readout__row {
3273
+ display: flex;
3274
+ align-items: center;
3275
+ justify-content: space-between;
3276
+ gap: 10px;
3277
+ padding: var(--axi-readout-pad, 7px) 0;
3278
+ }
3279
+ .axi-readout__row + .axi-readout__row {
3280
+ border-top: var(--axi-border-hairline) solid var(--axi-rule);
3281
+ }
3282
+ .axi-readout__k {
3283
+ min-width: 0;
3284
+ margin: 0;
3285
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3286
+ font: var(--axi-t-small);
3287
+ font-weight: 700;
3288
+ color: var(--axi-text-dim);
3289
+ }
3290
+ .axi-readout__v {
3291
+ flex: none;
3292
+ margin: 0;
3293
+ text-align: right;
3294
+ font: var(--axi-t-micro);
3295
+ letter-spacing: var(--axi-ls-micro);
3296
+ color: var(--axi-text);
3297
+ font-variant-numeric: tabular-nums;
3298
+ }
3299
+ .axi-panel--tile .axi-readout__row { padding-block: var(--axi-readout-pad, 3px); }
3300
+
2849
3301
  /* ---------- bars ---------- */
2850
3302
  /* The same rule stood on end. The baseline is drawn at the control weight
2851
3303
  because it is an axis - the one line in a chart that is structure rather
@@ -2975,6 +3427,163 @@ textarea.axi-input {
2975
3427
  text-transform: uppercase;
2976
3428
  color: var(--axi-text-dim);
2977
3429
  }
3430
+ /* A key you can press, to isolate the series it names. The state is the
3431
+ attribute rule 13 asks for: `aria-pressed="true"` on the key that is
3432
+ isolated, and nothing at all on the others - which of them recede is the
3433
+ legend's to work out, from whether any key is pressed, rather than a second
3434
+ attribute the consumer has to compute and keep in step.
3435
+
3436
+ Receding is a step down the neutral ramp and not an opacity, which is rule
3437
+ 4's "nothing fades" reaching the legend: the consumer this was written for
3438
+ had the rest of the keys at `opacity: .3` and they were unreadable, which
3439
+ is the one thing a legend must not be. A receded key brightens back to
3440
+ plain under the cursor so it can be found again - the hover is wrapped so it
3441
+ costs nothing against an ink, and the receded rule steps aside for it.
3442
+
3443
+ The button reset and the hover are :where()-wrapped for the reason the ink
3444
+ layer gives: an inked key keeps its ink. The pressed and receded colours are
3445
+ the state's own meaning and stay at full weight. */
3446
+ .axi-legend__key:where(button) {
3447
+ padding: 0;
3448
+ background: none;
3449
+ border: 0;
3450
+ cursor: pointer;
3451
+ transition: color .1s;
3452
+ }
3453
+ .axi-legend__key:where(button:hover) { color: var(--axi-text); }
3454
+ .axi-legend__key[aria-pressed="true"] { color: var(--axi-text); }
3455
+ .axi-legend:has([aria-pressed="true"]) .axi-legend__key:where(:not([aria-pressed="true"], :hover)) {
3456
+ color: var(--axi-text-faint);
3457
+ }
3458
+
3459
+ /* ---------- library charts ---------- */
3460
+ /* The language draws its own bars, plot and ticks above. An app that needs a
3461
+ stacked area, a brush or a pie reaches for a charting library instead, and
3462
+ the library arrives with its own furniture: a dashed grey grid, a #666 axis,
3463
+ a slate tooltip with a half-rem corner. Every series it draws is the
3464
+ consumer's data and keeps its own colour (rule 10). Everything drawn AROUND
3465
+ the series is furniture, and furniture is the language's.
3466
+
3467
+ This is the binding for recharts, which is the library the suite uses. It
3468
+ is scoped to `.axi-chart` so a chart opts in by wrapping, and it reaches the
3469
+ library's elements by the class names recharts puts on them. recharts spreads
3470
+ stroke and fill onto its SVG as presentation attributes, which a stylesheet
3471
+ outranks, so nothing here needs to be passed into the library - and nothing
3472
+ here is written with !important. A consumer that hands the library an inline
3473
+ `contentStyle` has written a second design for the tooltip, and the answer
3474
+ is to stop writing it, not for the language to shout over it: the tooltip is
3475
+ `.axi-tooltip--flow` as custom content, drawn once, like every other tooltip.
3476
+
3477
+ The grammar is the one every component here already follows. Furniture is
3478
+ drawn in the neutral ramp and recedes: the grid at --axi-grid, a step below
3479
+ the rule (see tokens.css); the axis as a rule at the hairline weight, the
3480
+ same line the table draws between rows; tick labels in the faint ink with
3481
+ tabular figures, so a y-axis whose digits change width holds still. Every
3482
+ small object standing on a series - a point, a bar, a slice - takes the ink
3483
+ edge rule 3 gives every other small object, at the hairline because it is a
3484
+ mark and not a raised surface, and keeps its fill, because the fill is which
3485
+ series it belongs to. The hover band is the raised step's -paint companion
3486
+ (a `fill` cannot be a gradient) rather than a white wash, which is rule 2.
3487
+
3488
+ The brush is the one place an accent is spent. Its two travellers are the
3489
+ only thing in any chart you can grab, and they are small: a target you have
3490
+ to find before you can use it is what the accent is for, unlike the hover
3491
+ band, which is already under the pointer. The slide between them is the
3492
+ surface as one paint, the trough behind them a well. One accent in the plot,
3493
+ on the one control.
3494
+
3495
+ Solid, never dashed. A dash is decoration, and fifteen charts of dashes is a
3496
+ texture rather than a scale; the reference line - a threshold, a claim - is
3497
+ solid too, because it is one of the few lines in a chart meant to be read. */
3498
+ .axi-chart {
3499
+ /* Every figure in a chart is a label of a number, so the whole scope reads
3500
+ tabular. Nothing else is declared on the scope: a chart's size belongs to
3501
+ the library's container and the consumer's layout. */
3502
+ font-variant-numeric: tabular-nums;
3503
+ }
3504
+ .axi-chart .recharts-cartesian-grid line {
3505
+ stroke: var(--axi-grid);
3506
+ stroke-dasharray: none;
3507
+ stroke-width: 1;
3508
+ }
3509
+ /* Stripes are a second background inside a chart that already has one. */
3510
+ .axi-chart .recharts-cartesian-grid-bg,
3511
+ .axi-chart .recharts-cartesian-gridstripes-horizontal,
3512
+ .axi-chart .recharts-cartesian-gridstripes-vertical { display: none; }
3513
+
3514
+ .axi-chart .recharts-cartesian-axis-line {
3515
+ stroke: var(--axi-rule);
3516
+ stroke-width: var(--axi-border-hairline);
3517
+ }
3518
+ .axi-chart .recharts-cartesian-axis-tick-line { stroke: var(--axi-rule); }
3519
+ .axi-chart .recharts-cartesian-axis-tick-value { fill: var(--axi-text-faint); }
3520
+ .axi-chart .recharts-label,
3521
+ .axi-chart .recharts-legend-item-text { fill: var(--axi-text-dim); color: var(--axi-text-dim); }
3522
+ /* The legend is a key, so it reads as labels rather than as chips - the same
3523
+ type .axi-legend__key sets. */
3524
+ .axi-chart .recharts-legend-wrapper {
3525
+ font: var(--axi-t-micro);
3526
+ letter-spacing: var(--axi-ls-micro);
3527
+ text-transform: uppercase;
3528
+ }
3529
+
3530
+ /* The hover band. A fill change rather than a wash: the next surface up, as
3531
+ one paint, edged with the rule at the hairline like every line here. */
3532
+ .axi-chart .recharts-tooltip-cursor {
3533
+ fill: var(--axi-surface-raised-paint);
3534
+ fill-opacity: 1;
3535
+ stroke: var(--axi-rule);
3536
+ stroke-width: var(--axi-border-hairline);
3537
+ }
3538
+ /* A line chart's cursor is a crosshair, not a band, so it stays a line. */
3539
+ .axi-chart .recharts-curve.recharts-tooltip-cursor {
3540
+ fill: none;
3541
+ stroke: var(--axi-rule);
3542
+ stroke-dasharray: none;
3543
+ }
3544
+
3545
+ /* Small objects standing on a series keep their fill and take the ink edge. */
3546
+ .axi-chart .recharts-line-dot,
3547
+ .axi-chart .recharts-scatter-symbol > path,
3548
+ .axi-chart .recharts-pie-sector > path,
3549
+ .axi-chart .recharts-bar-rectangle > path {
3550
+ stroke: var(--axi-ink-line);
3551
+ stroke-width: var(--axi-border-hairline);
3552
+ }
3553
+ .axi-chart .recharts-active-dot > * {
3554
+ stroke: var(--axi-ink-line);
3555
+ stroke-width: var(--axi-border-control);
3556
+ }
3557
+ .axi-chart .recharts-bar-background-rectangle { fill: var(--axi-well-fill); }
3558
+
3559
+ .axi-chart .recharts-reference-line-line {
3560
+ stroke: var(--axi-text-faint);
3561
+ stroke-width: var(--axi-border-hairline);
3562
+ stroke-dasharray: none;
3563
+ }
3564
+
3565
+ /* The brush: a well, a slide, two travellers, and the preview curve dropped to
3566
+ furniture weight because it is context rather than data you read off. */
3567
+ .axi-chart .recharts-brush > rect:first-of-type {
3568
+ fill: var(--axi-well-fill);
3569
+ stroke: var(--axi-well-line);
3570
+ stroke-width: var(--axi-border-control);
3571
+ }
3572
+ .axi-chart .recharts-brush-slide {
3573
+ fill: var(--axi-surface-paint);
3574
+ fill-opacity: 1;
3575
+ }
3576
+ .axi-chart .recharts-brush-traveller > rect {
3577
+ fill: var(--axi-accent);
3578
+ stroke: var(--axi-ink-line);
3579
+ stroke-width: var(--axi-border-hairline);
3580
+ }
3581
+ .axi-chart .recharts-brush-traveller > line { stroke: var(--axi-accent-ink); }
3582
+ .axi-chart .recharts-brush-texts { fill: var(--axi-text-faint); }
3583
+ .axi-chart .recharts-brush .recharts-curve {
3584
+ stroke: var(--axi-text-faint);
3585
+ fill: none;
3586
+ }
2978
3587
 
2979
3588
  /* --- feedback.css --- */
2980
3589
  /* axi design language - feedback.
@@ -3147,8 +3756,8 @@ textarea.axi-input {
3147
3756
 
3148
3757
  /* --- utilities.css --- */
3149
3758
  /* axi design language - utilities.
3150
- Two families of single-purpose class and one state, and the file is last in
3151
- the cascade
3759
+ Two families of single-purpose class, one state and one refusal, and the file
3760
+ is last in the cascade
3152
3761
  because that is the whole point of it: a utility has to be able to overrule
3153
3762
  the component it sits inside, and it is not allowed to buy that with
3154
3763
  !important or with a padded selector.
@@ -3251,6 +3860,7 @@ textarea.axi-input {
3251
3860
  .axi-chip--action:is(:disabled, [aria-disabled="true"]),
3252
3861
  .axi-drawer__close:is(:disabled, [aria-disabled="true"]),
3253
3862
  .axi-input:is(:disabled, [aria-disabled="true"]),
3863
+ .axi-legend__key:where(button):is(:disabled, [aria-disabled="true"]),
3254
3864
  .axi-link:is(:disabled, [aria-disabled="true"]),
3255
3865
  .axi-palette__row:is(:disabled, [aria-disabled="true"]),
3256
3866
  .axi-palette__trigger:is(:disabled, [aria-disabled="true"]),
@@ -3263,11 +3873,65 @@ textarea.axi-input {
3263
3873
  .axi-select:is(:disabled, [aria-disabled="true"]),
3264
3874
  .axi-switch:is(:disabled, [aria-disabled="true"]),
3265
3875
  .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"]) {
3876
+ .axi-menu__pop :where(label):is(:disabled, [aria-disabled="true"]),
3877
+ .axi-prose :where(a):is(:disabled, [aria-disabled="true"]),
3878
+ .axi-tabs :where(a, button):is(:disabled, [aria-disabled="true"]),
3879
+ .axi-titlebar__btns :where(button):is(:disabled, [aria-disabled="true"]) {
3271
3880
  opacity: .5;
3272
3881
  cursor: not-allowed;
3273
3882
  }
3883
+
3884
+ /* ---------- the quiet scroll ---------- */
3885
+ /* A scrollbar down the side of a narrow strip is a channel, not information.
3886
+
3887
+ The language had already decided this and said it in exactly one place, on
3888
+ .axi-palette__list, with the argument written inline: at twelve rows behind
3889
+ a fixed cap the bar reports a fact the row count in the bar has already
3890
+ given. Said only there, it was the language's only word for the decision -
3891
+ and the consumer that needed the same thing for its own strips could not
3892
+ reach it, so it wrote the rule again. Five times, in its own stylesheet, for
3893
+ two rails and a picker slot, twice with a `*` descendant arm because the
3894
+ element that actually scrolls sits one level inside a component it does not
3895
+ control. That is the third instance of the pattern in docs/RULES.md ("A style
3896
+ only reachable through a layer will be re-invented"), and the remedy is the
3897
+ same one: give the decision a name a consumer can spend, and put every
3898
+ spelling of it in ONE rule.
3899
+
3900
+ So: one rule. The class is what a consumer says about its own scroller; the
3901
+ four component parts beside it are the strips the language ships. Two rules
3902
+ that agree today are two rules that disagree after the next edit.
3903
+
3904
+ This is a refusal rather than a utility - it says what NOT to draw - which is
3905
+ why it is here rather than beside each component even though nothing needs
3906
+ the last-in-cascade position to win. One fact about parts living in three
3907
+ different source files has one home, the same as the dead state above.
3908
+
3909
+ Why these four and not everything that scrolls. The strip is the test: a
3910
+ rail is 208px wide and a palette list sits in a small floating panel, so the
3911
+ bar costs a measurable share of the width AND draws a second vertical line
3912
+ beside the object's own edge. A table's horizontal bar is the opposite case
3913
+ and .axi-table__scroll keeps it on purpose (see src/data.css) - there the bar
3914
+ is the only thing telling you a column is off-screen. Nothing here reaches a
3915
+ descendant: `*` would take the table's bar away the moment someone put one
3916
+ in a rail.
3917
+
3918
+ `.axi-rail` and `.axi-rail__nav` both, because either can be the scroller.
3919
+ The rail declares `overflow-y: auto` and is the scroller by default, but a
3920
+ rail with something pinned below its list - a "back" action at the floor -
3921
+ scrolls the nav instead and leaves the rail still. Both are the same strip
3922
+ and the decision cannot depend on which one the consumer chose. */
3923
+ .axi-scroll-quiet,
3924
+ .axi-palette__list,
3925
+ .axi-rail,
3926
+ .axi-rail__nav,
3927
+ .axi-split__nav {
3928
+ scrollbar-width: none;
3929
+ }
3930
+ .axi-scroll-quiet::-webkit-scrollbar,
3931
+ .axi-palette__list::-webkit-scrollbar,
3932
+ .axi-rail::-webkit-scrollbar,
3933
+ .axi-rail__nav::-webkit-scrollbar,
3934
+ .axi-split__nav::-webkit-scrollbar {
3935
+ width: 0;
3936
+ height: 0;
3937
+ }