@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.
@@ -16,6 +16,7 @@
16
16
  --axi-text: #e8eaed;
17
17
  --axi-text-dim: #8b929e;
18
18
  --axi-text-faint: #5d6577;
19
+ --axi-scrim: rgba(4, 5, 8, .72);
19
20
  --axi-shadow-panel: 0 2px 8px rgba(0, 0, 0, .4), 0 1px 3px rgba(0, 0, 0, .3), inset 0 1px 0 rgba(255, 255, 255, .05);
20
21
  --axi-shadow-control: 0 1px 4px rgba(0, 0, 0, .3), inset 0 1px 0 rgba(255, 255, 255, .045);
21
22
  --axi-shadow-panel-hover: 0 6px 18px rgba(0, 0, 0, .5), 0 2px 5px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .07);
package/docs/RULES.md CHANGED
@@ -222,6 +222,31 @@ Every lift is turned off under `@media (prefers-reduced-motion: reduce)`, in
222
222
  diamond still rotates, because a rotation that never changes is geometry and
223
223
  not motion.
224
224
 
225
+ ### A hover reveals no control
226
+
227
+ A control that exists only while the cursor is over it exists for nobody on a
228
+ touch screen, for nobody using a keyboard, and for nobody who has not already
229
+ found it. Three sites in one consumer had this shape: an open-link glyph at
230
+ `opacity: 0` until its card was hovered, and two help tips faded in the same
231
+ way. The glyph was the only way off the card. On a phone it was not there.
232
+
233
+ So: **a hover changes an element's depth, and may brighten its ink, and never
234
+ its presence.** What is on screen is on screen. If a control is worth having it
235
+ is drawn at rest, and if it is not worth drawing at rest it is not worth having.
236
+
237
+ The one thing a hover may bring in is an annotation of something already
238
+ present — a `.axi-tooltip` naming what a glyph does — and only because the
239
+ same tip answers focus. A tip that answers hover alone is a control-shaped
240
+ hole for everyone the first paragraph names.
241
+
242
+ The other half of this is a **dimmed** thing, which is what a consumer reaches
243
+ for when one series in a legend is isolated and the rest should recede.
244
+ `opacity: .3` on the rest is rule 2's faded ink, and it is what left those
245
+ keys unreadable. Receding is a step down the neutral ramp — `--axi-text-faint`
246
+ — which stays legible at every step, and a hover brings a receded key back to
247
+ plain so it can be found again. Nothing in the language fades, and this is one
248
+ more place that holds.
249
+
225
250
  ## 5. Filled means status, outlined means annotation
226
251
 
227
252
  A filled chip asserts a value about the thing. An outlined chip in the cool ink
@@ -323,6 +348,21 @@ a colour that is a fill's contrast pair — `.axi-btn--primary`'s accent ink —
323
348
  too, because an ink there would put a status colour on the accent block and cost
324
349
  the label its legibility, which is rule 5's reason for the chip.
325
350
 
351
+ Applying this everywhere it was owed had one consequence worth naming, because
352
+ it is the rule arriving rather than a regression: hovering the **current**
353
+ breadcrumb used to turn it accent, and now it does not move. `[aria-current]`
354
+ outweighs a wrapped hover, which is the rail's stated refusal — the current item
355
+ does not brighten further under the cursor — reaching the one component that had
356
+ been disagreeing with it by accident of specificity.
357
+
358
+ Two things about *checking* this, both learned by getting them wrong. Weigh one
359
+ compound, never a selector list: a rule that lists two wrapped hovers beside a
360
+ state weighs as the state if you measure the list, and reports the two correct
361
+ hovers as offenders. And compare a hover only against a resting rule that
362
+ matches the **same element**: an `<a>` inside `.axi-prose` takes the container's
363
+ colour by inheritance, which no specificity can lose to, so measuring the link's
364
+ hover against the container's rule asks a question neither rule is answering.
365
+
326
366
  ## 7. The diamond is the family motif
327
367
 
328
368
  A 45°-rotated outlined square. Bullet, status dot, language marker, and scaled
@@ -404,6 +444,51 @@ fact has no magnitude to draw: rendering "no" as a short bar says "a little
404
444
  bit" as loudly as a faded fill says "30%". That series is a row of marks of
405
445
  one size, differing only in ink, which is `.axi-ticks`.
406
446
 
447
+ ### The matrix is the bound on "not at all"
448
+
449
+ The first corollary says this language does not draw a heatmap, and as written
450
+ that is too wide. It is true of a *distribution* — one series along one
451
+ categorical axis — because there a bar is always available, and reaching for
452
+ tint over length is choosing the illegible encoding when the legible one was
453
+ free.
454
+
455
+ A matrix is not that shape. Two categorical axes, both of them orderings the
456
+ reader navigates by — forty players down the side, sixty five-second buckets
457
+ across — and a quantity at each intersection. The plane is spent. There is no
458
+ third dimension left to give a length to, a bar per cell is 2400 bars four
459
+ pixels wide, and neither axis can be re-sorted by the value because both are
460
+ already sorted by something the reader needs: down the side by subgroup, across
461
+ by time. "Or not at all" would mean the shape is undrawable, and it is the only
462
+ shape that answers *who was doing this, and when*.
463
+
464
+ So a matrix may encode its quantity as intensity, under one condition — and the
465
+ condition is rule 9's own argument rather than an exemption from it. Rule 9 does
466
+ not object to intensity. It objects to intensity being the ONLY copy of the
467
+ number, and the illegible one. **A matrix cell prints its value.** The digit is
468
+ the legible copy, the band is what lets the eye find the shape without reading
469
+ two thousand numbers one at a time, and a reader who wants a figure reads the
470
+ figure. A matrix cell with no number in it is a heatmap, and for a heatmap the
471
+ corollary stands exactly as written.
472
+
473
+ Two further bounds, both of them rules already here rather than new ones.
474
+
475
+ The steps are **discrete and opaque**. A continuous alpha ramp of the accent
476
+ over the field is a faded accent, which is rule 2 — and it fails on its own
477
+ terms as well, because the cells a reader scans for are the low ones and those
478
+ are the ones a ramp makes hardest to see. Four steps, each a `color-mix()` of
479
+ the accent into `--axi-surface-paint`, so every band is a computed opaque
480
+ colour. The flat companion and not `--axi-surface`, because a surface token is
481
+ allowed to hold a gradient and `color-mix()` takes colours only; get that wrong
482
+ and the bands do not fade, they vanish.
483
+
484
+ And the band is the cell's **fill**, which settles what a row state may do to
485
+ it. Hover and selection raise a row by filling its cells, and a cell whose fill
486
+ is the data has no room for that — so on any table, a cell carrying a value in
487
+ its fill keeps it, and the row state is drawn by the leading edge and by every
488
+ cell that has nothing to say. That is not a concession to the matrix: it is the
489
+ edge-not-fill answer rule 8's selection already gives, arriving a second time
490
+ for the same reason.
491
+
407
492
  ## 10. A chart's ink is the accent
408
493
 
409
494
  One series is the accent. A second, for comparison, is the neutral ramp —
@@ -421,6 +506,28 @@ their meanings inside a chart, so nothing else may borrow them for a category.
421
506
  The status inks still mean status inside a plot: a line drawn in `--axi-danger`
422
507
  is asserting that the quantity is bad, not that it is the third series.
423
508
 
509
+ ### A chart's furniture recedes
510
+
511
+ Rule 10 is about the series. Everything drawn *around* a series - gridlines,
512
+ the axis, tick labels, the hover band, a brush - is furniture, and furniture is
513
+ drawn in the neutral ramp so the series are the only colour in the plot. The
514
+ grid sits a step below the rule, at `--axi-grid`, because a rule divides two
515
+ things and a grid is only what a value is read against; the axis is a rule at
516
+ the hairline; tick labels are the faint ink with tabular figures. A point, a
517
+ bar or a slice keeps its own fill and takes the hairline ink edge every small
518
+ object takes under rule 3. Nothing in a chart is dashed: a dash is decoration,
519
+ and a threshold is one of the few lines in a chart meant to be read.
520
+
521
+ One accent is spent inside a plot, and only on the one thing in it you can
522
+ grab - a brush's travellers. The hover band is already under the pointer and
523
+ needs no colour to be found.
524
+
525
+ An app that draws its charts with a library gets the same furniture by
526
+ wrapping the chart in `.axi-chart`, which binds these decisions to the
527
+ library's own element classes. The binding is written without `!important`:
528
+ a consumer that hands the library an inline style has drawn a second design,
529
+ and the remedy is to stop handing it, not for the language to shout over it.
530
+
424
531
  ## 11. An indicator of work animates a composited property
425
532
 
426
533
  Spinners, progress strips and pulses almost always report on something
@@ -501,6 +608,48 @@ that must be rejected, one per prohibition. The rule above is therefore a check
501
608
  rather than a promise — the same treatment rule 3's weights get in
502
609
  `tests/tokens.test.mjs`.
503
610
 
611
+ ## 13. A state is an attribute, and the appearance follows it
612
+
613
+ Every state this language draws is keyed off an attribute the element already
614
+ carries, never off a class invented to describe the look. `[aria-current]` on a
615
+ rail item, a tab, a table row and a picked panel. `[aria-pressed="true"]` on a
616
+ pill. `[aria-selected="true"]` on a listbox option. `[aria-disabled="true"]`
617
+ beside `:disabled`, in 26 places. `[aria-sort]` on the sorted column,
618
+ `[aria-expanded]` on the thing that opens. One spelling per semantics, and the
619
+ semantics decides which — not the appearance, which is why a rail item and a
620
+ picked panel share `[aria-current]` while looking nothing alike.
621
+
622
+ The reason is not tidiness. **A state that exists only as an appearance is not a
623
+ state.** A consumer that marks the chosen card with a class has drawn a mark
624
+ sighted users can see and told everyone else nothing, and no amount of styling
625
+ fixes it from our side — the information was never in the document. Keying the
626
+ style off the attribute makes the two inseparable: you cannot get the look
627
+ without emitting the state, and you cannot emit the state and fail to get the
628
+ look.
629
+
630
+ This is also what stops the language growing a second vocabulary. An invented
631
+ `--selected` modifier would be a synonym for `[aria-current]` that a screen
632
+ reader cannot read, and the two would drift the first time one of them got a
633
+ tweak. There is no `.axi-panel--selected` for the same reason there is no
634
+ `.axi-btn--off`.
635
+
636
+ Two consequences when adding a component:
637
+
638
+ - **Find the attribute before writing the rule.** If the state the component
639
+ needs already has an ARIA spelling, use it, even if the look is unlike every
640
+ other user of that attribute. If it genuinely has none, that is the moment to
641
+ ask whether the state is real.
642
+ - **A state the markup holds needs no attribute at all.** A `<label>` wrapping
643
+ its own radio is the correct markup for a picker; the input holds the state,
644
+ so the label has nothing to set, and copying it onto the label would be a
645
+ second source of truth that can disagree with the first. That case is matched
646
+ structurally — `:has(> input:checked)`, the language's only `:has()`, with the
647
+ child combinator load-bearing: a descendant match would fire on any checkbox
648
+ buried in the component's content.
649
+
650
+ `tests/tokens.test.mjs` holds this rule to the components that carry it, so it
651
+ is a check rather than a promise.
652
+
504
653
  ## Tokens
505
654
 
506
655
  Three layers, in `src/tokens.css` — the only file permitted to contain a colour
@@ -795,10 +944,30 @@ allowed to come apart, they had already come apart. When you lift a
795
944
  layer-scoped style out, look for the declarations the layer was getting for
796
945
  free from its element. Those are the ones the new spelling silently loses.
797
946
 
798
- Two instances is a pattern, so the check belongs at the top of the list when
799
- adding anything: grep `src/` for the component's style living behind a layer
800
- prefix. If it does, it has consumers you cannot see, and they have already
801
- drawn their own.
947
+ The third instance was not behind a layer prefix at all, and it widens the
948
+ rule. `.axi-palette__list` hid its scrollbar with the argument written inline —
949
+ a bar down the side of a small panel reporting a fact the row count has already
950
+ given. That was the language's only word for the decision, and the decision was
951
+ about *strips*, not about palettes. The same consumer needed it for two rails
952
+ and a picker slot and could not reach it, so it wrote the rule again five times
953
+ in its own stylesheet — twice with a `*` descendant arm, because the element
954
+ that actually scrolls sits one level inside a component it does not control,
955
+ which is the sledgehammer a consumer reaches for when the language gives it no
956
+ name. The remedy is unchanged: one rule, every spelling in it, and a class the
957
+ consumer can spend (`.axi-scroll-quiet`, in `src/utilities.css`).
958
+
959
+ So the check is not only "is this style behind a layer prefix". It is **is this
960
+ style the only statement of a decision that is broader than the component
961
+ stating it**. A component's own inline reasoning is the tell: if the comment
962
+ argues about a category of thing — a strip, a recess, a reading — and the
963
+ selector names one member of that category, the rest of the category has
964
+ nowhere to look.
965
+
966
+ Three instances, so the check belongs at the top of the list when adding
967
+ anything: grep `src/` for the component's style living behind a layer prefix,
968
+ and read the comments on any single-selector rule you are about to copy. If
969
+ either applies, it has consumers you cannot see, and they have already drawn
970
+ their own.
802
971
 
803
972
  ### A refusal holds at every level, not just the one it was written for
804
973
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/axi-design",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "The design language for the axi suite — flat and outlined, dark, drawn in saturated ink.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/base.css CHANGED
@@ -72,3 +72,43 @@ a { color: inherit; }
72
72
  transform: none !important;
73
73
  }
74
74
  }
75
+
76
+ /* The scrollbar is chrome, and chrome is drawn in the language's own ink. A
77
+ browser's default bar is a light capsule on a light channel, which is two
78
+ things this language does not have: a colour at partial opacity over the
79
+ ground (rule 2) and the one radius the system refuses (see --axi-radius).
80
+ The consumer that first themed it had written both anyway - a 999px thumb
81
+ in white at 35% - in its own stylesheet, because the language said nothing
82
+ here, and a decision the language leaves unsaid is one every consumer makes
83
+ differently.
84
+
85
+ So: the thumb is the rule ink, a rectangle, in a transparent channel. The
86
+ channel is transparent rather than the ground for the reason the well's fill
87
+ gives: an opaque page colour inside a translucent pane is a black patch, and
88
+ a bar runs down the inside of panes and wells as often as down the page.
89
+
90
+ Two spellings, one per engine, and neither is a fallback for the other.
91
+ `::-webkit-scrollbar` is the only spelling that controls the SHAPE - the
92
+ standard `scrollbar-color` pair recolours whatever the engine draws, and
93
+ what Chromium draws is a capsule with arrow buttons, the shape this language
94
+ refuses. Worse, Chromium (121+) switches the pseudo-elements off entirely the
95
+ moment either standard property is set, so writing both does not combine
96
+ them: it hands Chromium the capsule and throws the rectangle away. (The
97
+ consumer this replaces had written both, and had been tuning a webkit block
98
+ Chromium stopped reading, for years.) So the standard pair is scoped to the
99
+ engines that have no pseudo-element to disable, which today is Firefox, and
100
+ the pseudo-elements carry the design everywhere else. scrollbar-color
101
+ inherits, so the root says it once; scrollbar-width does not, so it is set
102
+ on everything.
103
+
104
+ The quiet-scroll rule in utilities.css still wins where it applies: those
105
+ selectors carry a class and these carry none, so a strip that has asked for
106
+ no bar gets none, and everything else gets this one. */
107
+ @supports not selector(::-webkit-scrollbar) {
108
+ html { scrollbar-color: var(--axi-rule) transparent; }
109
+ * { scrollbar-width: thin; }
110
+ }
111
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
112
+ ::-webkit-scrollbar-track,
113
+ ::-webkit-scrollbar-corner { background: transparent; }
114
+ ::-webkit-scrollbar-thumb { background: var(--axi-rule); border-radius: 0; }
package/src/data.css CHANGED
@@ -139,7 +139,17 @@
139
139
  resting cell rule (`.axi-table :where(td, tbody th)`, also one class) and
140
140
  wins on order, while an ink class in utilities.css - one class, later file -
141
141
  wins over both. */
142
- .axi-table :where(tbody tr:hover) :where(td, th) {
142
+ /* A DATA FILL SURVIVES A ROW STATE, and the `:not()` sits inside `:where()`
143
+ so saying so costs no specificity. A matrix cell's band IS its value (rule 9,
144
+ "the matrix is the bound on not at all") - washing it on the way past the row
145
+ you want deletes the reading. The row state is not weakened by this: it is
146
+ drawn by the leading edge below, and by every cell that has nothing to say.
147
+ Which is the edge-not-fill answer the selection rule below reaches on its own
148
+ grounds, arriving here a second time.
149
+
150
+ Nothing changes for a table with no `data-heat` in it, which is every table
151
+ in this language but one. */
152
+ .axi-table :where(tbody tr:hover) :where(td:not([data-heat]), th:not([data-heat])) {
143
153
  background: var(--axi-surface-raised);
144
154
  background-attachment: fixed;
145
155
  color: var(--axi-text);
@@ -174,7 +184,7 @@
174
184
  .axi-table :where(tbody tr) > :where(td, th):first-child {
175
185
  border-inline-start: var(--axi-border-control) solid transparent;
176
186
  }
177
- .axi-table tbody tr[aria-current] :is(td, th) {
187
+ .axi-table tbody tr[aria-current] :is(td, th):where(:not([data-heat])) {
178
188
  background: var(--axi-surface-raised);
179
189
  background-attachment: fixed;
180
190
  color: var(--axi-text);
@@ -335,6 +345,115 @@
335
345
  }
336
346
  .axi-table th:first-child .axi-table__sort { justify-content: flex-start; }
337
347
 
348
+ /* ---------- matrix ---------- */
349
+ /* Two categorical axes and a quantity where they cross: forty players down the
350
+ side, sixty five-second buckets across, a count in each cell. Rule 9's
351
+ "the matrix is the bound on not at all" is the whole argument for this
352
+ existing at all, and the short form is: the plane is spent, so there is no
353
+ third dimension left to give the quantity a LENGTH, and both axes are
354
+ already sorted by something the reader needs so neither can be re-sorted by
355
+ the value. Intensity is what is left, and it is admissible here because the
356
+ cell also prints its number - the digit is the legible copy rule 9 demands,
357
+ and the band is only what lets the eye find the shape without reading two
358
+ thousand figures one at a time. A matrix cell with nothing written in it is
359
+ a heatmap and rule 9 refuses it.
360
+
361
+ This is a MODIFIER and not a component, and that is the finding rather than
362
+ a convenience. Everything structural a matrix field needs, this table
363
+ already had: --sticky for the ruler that stays, --pinned for the names that
364
+ stay, --dense for sixty columns' padding, --fixed plus a <colgroup> for
365
+ proportional cells, and .axi-table__scroll for the frame they move inside.
366
+ The consumer measured against here had hand-written all five, and had got
367
+ --sticky wrong in exactly the way that modifier's comment warns about: its
368
+ ruler was --axi-surface-raised, so under glass the rows slid through it.
369
+ The only thing genuinely missing was the quantity.
370
+
371
+ What the modifier changes is the CELL, which stops being a number in a
372
+ column and becomes a patch in a field: centred rather than right-aligned,
373
+ because a band is read by position and not by its digits lining up, and
374
+ unpadded because the padding is the field. The name column keeps the base
375
+ table's left alignment and its gutter - it is still a name. */
376
+ .axi-table--matrix { border-collapse: separate; border-spacing: 0; }
377
+ .axi-table--matrix thead th { padding: 0 0 6px; text-align: center; }
378
+ .axi-table--matrix :is(td, tbody th) {
379
+ padding: 0;
380
+ text-align: center;
381
+ height: var(--axi-matrix-cell, 24px);
382
+ }
383
+ /* `min-width` and not `width`: under the default auto layout this is the floor
384
+ a one-digit column cannot fall below, and under --fixed a <colgroup> takes
385
+ over entirely (fixed layout ignores both min and max - see --fixed). */
386
+ .axi-table--matrix :is(td, tbody th):not(:first-child) {
387
+ min-width: var(--axi-matrix-cell, 24px);
388
+ }
389
+ .axi-table--matrix :is(td, tbody th):first-child {
390
+ text-align: left;
391
+ padding: 0 10px 0 0;
392
+ }
393
+ /* The four bands. Each is a color-mix() of the accent into the surface's flat
394
+ companion, which makes every one of them a computed OPAQUE colour - the
395
+ accent at 18% alpha over the field would be rule 2's faded ink, and it also
396
+ fails on its own terms, because the cells a reader is scanning for are the
397
+ quiet ones and an alpha ramp is where those disappear.
398
+
399
+ --axi-surface-paint and not --axi-surface, and this is the trap: a surface
400
+ token is allowed to hold a gradient (rule 1's one relief, which is how a
401
+ glass theme exists), color-mix() takes colours and nothing else, and an
402
+ invalid color-mix() is dropped at computed-value time. Spell it --axi-surface
403
+ and the bands do not fade - they vanish, silently, in whichever theme paints
404
+ a gradient. That is measured, not hypothetical: it is why
405
+ --axi-surface-paint exists (see tokens.css).
406
+
407
+ Four steps and not a continuous ramp, because a band is also where the digit
408
+ has to change ink, and a step is the only thing a flip can happen ON. The
409
+ top two carry --axi-accent-ink for the same reason .axi-btn--primary does:
410
+ past roughly half strength the field is the accent and the text on it is the
411
+ accent's companion, not the text ramp. */
412
+ .axi-table--matrix :is(td, tbody th)[data-heat='1'] {
413
+ background-color: color-mix(in srgb, var(--axi-accent) 18%, var(--axi-surface-paint));
414
+ }
415
+ .axi-table--matrix :is(td, tbody th)[data-heat='2'] {
416
+ background-color: color-mix(in srgb, var(--axi-accent) 42%, var(--axi-surface-paint));
417
+ }
418
+ .axi-table--matrix :is(td, tbody th)[data-heat='3'] {
419
+ background-color: color-mix(in srgb, var(--axi-accent) 70%, var(--axi-surface-paint));
420
+ color: var(--axi-accent-ink);
421
+ }
422
+ .axi-table--matrix :is(td, tbody th)[data-heat='4'] {
423
+ background-color: color-mix(in srgb, var(--axi-accent) 100%, var(--axi-surface-paint));
424
+ color: var(--axi-accent-ink);
425
+ }
426
+ /* A matrix whose columns are a TIMELINE rather than a list of categories, which
427
+ changes two things and only two.
428
+
429
+ The column labels move left. A label over a category names the column and
430
+ belongs centred over it; a label on a ruler names a MOMENT, and centring it
431
+ puts the text half a cell to the right of the instant it is pointing at.
432
+
433
+ And the division gets a line. `[data-tick]` marks the columns that carry a
434
+ label - every thirtieth second, not every bucket, because at a five-second
435
+ resolution a five-minute fight is sixty columns and a timestamp over each is
436
+ unreadable at the width a cell allows. The line is the RULE, at the hairline
437
+ step: it is the same line that parts the rows, continued down the field,
438
+ which is the whole reason it is not drawn on every column. Sixty ruled
439
+ columns is a spreadsheet; the bands are meant to be the figure. */
440
+ .axi-table--ruler thead th { text-align: left; }
441
+ .axi-table--ruler :is(thead th, td, tbody th)[data-tick] {
442
+ border-left: var(--axi-border-hairline) solid var(--axi-rule);
443
+ }
444
+ /* A change of category down the rows - a subgroup, a team, a date. Heavier than
445
+ the hairline that parts two rows of one group, and heavier in INK rather than
446
+ in weight: the same line, drawn darker. Going up a form step instead would
447
+ put a control-weight line inside running content, which is rule 8's grid of
448
+ boxes, and the boundary would then compete with the head's own lid.
449
+
450
+ An attribute and not a class, per rule 13, and on the row because that is
451
+ what starts: the consumer compares one row's category with the previous
452
+ row's, which is a fact only it can know. */
453
+ .axi-table tbody tr[data-group-start] > :is(td, th) {
454
+ border-top: var(--axi-border-hairline) solid var(--axi-ink-line);
455
+ }
456
+
338
457
  /* ---------- meter ---------- */
339
458
  /* Rule 9: a proportion is a length. The track is the ground, the fill is the
340
459
  value, and the fill is one ink at full strength - a tinted or faded bar is
@@ -384,6 +503,61 @@
384
503
  font-variant-numeric: tabular-nums;
385
504
  }
386
505
 
506
+ /* ---------- readout ---------- */
507
+ /* A short run of labelled readings: logs seen, uploaded, failed; the session's
508
+ start and its length; a setting and the switch that sets it. The shape of
509
+ every status card in a dashboard's side column, and the one the consumer
510
+ that had four of them built by hand each time.
511
+
512
+ Rule 8 settles how it is drawn. The eye runs down the value column - that is
513
+ the whole point of stacking the readings - so it is a table's interior:
514
+ rows parted by the rule at the hairline weight, no outline and no block on
515
+ any row, nothing raised inside the panel that is already the raised thing.
516
+ It is not a table element because it is two cells wide, has no head, and
517
+ the reading in the second cell is as often a control as a figure; a <dl> is
518
+ what the document structure calls for and the classes sit on it.
519
+
520
+ The key is the meter list's name and the value is the meter list's value,
521
+ restated here rather than aliased because the two lists are laid out
522
+ differently and rule-8's test - is there a column? - is the same for both.
523
+
524
+ Row padding is a knob at two fallbacks, like the eyebrow's gap: 7px at
525
+ panel density, and 3px inside a tile, where a hairline already holds the
526
+ rows apart and 3px is still a clear gap at this type size. */
527
+ .axi-readout {
528
+ display: flex;
529
+ flex-direction: column;
530
+ margin: 0;
531
+ }
532
+ .axi-readout__row {
533
+ display: flex;
534
+ align-items: center;
535
+ justify-content: space-between;
536
+ gap: 10px;
537
+ padding: var(--axi-readout-pad, 7px) 0;
538
+ }
539
+ .axi-readout__row + .axi-readout__row {
540
+ border-top: var(--axi-border-hairline) solid var(--axi-rule);
541
+ }
542
+ .axi-readout__k {
543
+ min-width: 0;
544
+ margin: 0;
545
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
546
+ font: var(--axi-t-small);
547
+ font-weight: 700;
548
+ color: var(--axi-text-dim);
549
+ }
550
+ .axi-readout__v {
551
+ flex: none;
552
+ margin: 0;
553
+ text-align: right;
554
+ font: var(--axi-t-micro);
555
+ letter-spacing: var(--axi-ls-micro);
556
+ color: var(--axi-text);
557
+ font-variant-numeric: tabular-nums;
558
+ }
559
+ .axi-panel--tile .axi-readout__row { padding-block: var(--axi-readout-pad, 3px); }
560
+
387
561
  /* ---------- bars ---------- */
388
562
  /* The same rule stood on end. The baseline is drawn at the control weight
389
563
  because it is an axis - the one line in a chart that is structure rather
@@ -513,3 +687,160 @@
513
687
  text-transform: uppercase;
514
688
  color: var(--axi-text-dim);
515
689
  }
690
+ /* A key you can press, to isolate the series it names. The state is the
691
+ attribute rule 13 asks for: `aria-pressed="true"` on the key that is
692
+ isolated, and nothing at all on the others - which of them recede is the
693
+ legend's to work out, from whether any key is pressed, rather than a second
694
+ attribute the consumer has to compute and keep in step.
695
+
696
+ Receding is a step down the neutral ramp and not an opacity, which is rule
697
+ 4's "nothing fades" reaching the legend: the consumer this was written for
698
+ had the rest of the keys at `opacity: .3` and they were unreadable, which
699
+ is the one thing a legend must not be. A receded key brightens back to
700
+ plain under the cursor so it can be found again - the hover is wrapped so it
701
+ costs nothing against an ink, and the receded rule steps aside for it.
702
+
703
+ The button reset and the hover are :where()-wrapped for the reason the ink
704
+ layer gives: an inked key keeps its ink. The pressed and receded colours are
705
+ the state's own meaning and stay at full weight. */
706
+ .axi-legend__key:where(button) {
707
+ padding: 0;
708
+ background: none;
709
+ border: 0;
710
+ cursor: pointer;
711
+ transition: color .1s;
712
+ }
713
+ .axi-legend__key:where(button:hover) { color: var(--axi-text); }
714
+ .axi-legend__key[aria-pressed="true"] { color: var(--axi-text); }
715
+ .axi-legend:has([aria-pressed="true"]) .axi-legend__key:where(:not([aria-pressed="true"], :hover)) {
716
+ color: var(--axi-text-faint);
717
+ }
718
+
719
+ /* ---------- library charts ---------- */
720
+ /* The language draws its own bars, plot and ticks above. An app that needs a
721
+ stacked area, a brush or a pie reaches for a charting library instead, and
722
+ the library arrives with its own furniture: a dashed grey grid, a #666 axis,
723
+ a slate tooltip with a half-rem corner. Every series it draws is the
724
+ consumer's data and keeps its own colour (rule 10). Everything drawn AROUND
725
+ the series is furniture, and furniture is the language's.
726
+
727
+ This is the binding for recharts, which is the library the suite uses. It
728
+ is scoped to `.axi-chart` so a chart opts in by wrapping, and it reaches the
729
+ library's elements by the class names recharts puts on them. recharts spreads
730
+ stroke and fill onto its SVG as presentation attributes, which a stylesheet
731
+ outranks, so nothing here needs to be passed into the library - and nothing
732
+ here is written with !important. A consumer that hands the library an inline
733
+ `contentStyle` has written a second design for the tooltip, and the answer
734
+ is to stop writing it, not for the language to shout over it: the tooltip is
735
+ `.axi-tooltip--flow` as custom content, drawn once, like every other tooltip.
736
+
737
+ The grammar is the one every component here already follows. Furniture is
738
+ drawn in the neutral ramp and recedes: the grid at --axi-grid, a step below
739
+ the rule (see tokens.css); the axis as a rule at the hairline weight, the
740
+ same line the table draws between rows; tick labels in the faint ink with
741
+ tabular figures, so a y-axis whose digits change width holds still. Every
742
+ small object standing on a series - a point, a bar, a slice - takes the ink
743
+ edge rule 3 gives every other small object, at the hairline because it is a
744
+ mark and not a raised surface, and keeps its fill, because the fill is which
745
+ series it belongs to. The hover band is the raised step's -paint companion
746
+ (a `fill` cannot be a gradient) rather than a white wash, which is rule 2.
747
+
748
+ The brush is the one place an accent is spent. Its two travellers are the
749
+ only thing in any chart you can grab, and they are small: a target you have
750
+ to find before you can use it is what the accent is for, unlike the hover
751
+ band, which is already under the pointer. The slide between them is the
752
+ surface as one paint, the trough behind them a well. One accent in the plot,
753
+ on the one control.
754
+
755
+ Solid, never dashed. A dash is decoration, and fifteen charts of dashes is a
756
+ texture rather than a scale; the reference line - a threshold, a claim - is
757
+ solid too, because it is one of the few lines in a chart meant to be read. */
758
+ .axi-chart {
759
+ /* Every figure in a chart is a label of a number, so the whole scope reads
760
+ tabular. Nothing else is declared on the scope: a chart's size belongs to
761
+ the library's container and the consumer's layout. */
762
+ font-variant-numeric: tabular-nums;
763
+ }
764
+ .axi-chart .recharts-cartesian-grid line {
765
+ stroke: var(--axi-grid);
766
+ stroke-dasharray: none;
767
+ stroke-width: 1;
768
+ }
769
+ /* Stripes are a second background inside a chart that already has one. */
770
+ .axi-chart .recharts-cartesian-grid-bg,
771
+ .axi-chart .recharts-cartesian-gridstripes-horizontal,
772
+ .axi-chart .recharts-cartesian-gridstripes-vertical { display: none; }
773
+
774
+ .axi-chart .recharts-cartesian-axis-line {
775
+ stroke: var(--axi-rule);
776
+ stroke-width: var(--axi-border-hairline);
777
+ }
778
+ .axi-chart .recharts-cartesian-axis-tick-line { stroke: var(--axi-rule); }
779
+ .axi-chart .recharts-cartesian-axis-tick-value { fill: var(--axi-text-faint); }
780
+ .axi-chart .recharts-label,
781
+ .axi-chart .recharts-legend-item-text { fill: var(--axi-text-dim); color: var(--axi-text-dim); }
782
+ /* The legend is a key, so it reads as labels rather than as chips - the same
783
+ type .axi-legend__key sets. */
784
+ .axi-chart .recharts-legend-wrapper {
785
+ font: var(--axi-t-micro);
786
+ letter-spacing: var(--axi-ls-micro);
787
+ text-transform: uppercase;
788
+ }
789
+
790
+ /* The hover band. A fill change rather than a wash: the next surface up, as
791
+ one paint, edged with the rule at the hairline like every line here. */
792
+ .axi-chart .recharts-tooltip-cursor {
793
+ fill: var(--axi-surface-raised-paint);
794
+ fill-opacity: 1;
795
+ stroke: var(--axi-rule);
796
+ stroke-width: var(--axi-border-hairline);
797
+ }
798
+ /* A line chart's cursor is a crosshair, not a band, so it stays a line. */
799
+ .axi-chart .recharts-curve.recharts-tooltip-cursor {
800
+ fill: none;
801
+ stroke: var(--axi-rule);
802
+ stroke-dasharray: none;
803
+ }
804
+
805
+ /* Small objects standing on a series keep their fill and take the ink edge. */
806
+ .axi-chart .recharts-line-dot,
807
+ .axi-chart .recharts-scatter-symbol > path,
808
+ .axi-chart .recharts-pie-sector > path,
809
+ .axi-chart .recharts-bar-rectangle > path {
810
+ stroke: var(--axi-ink-line);
811
+ stroke-width: var(--axi-border-hairline);
812
+ }
813
+ .axi-chart .recharts-active-dot > * {
814
+ stroke: var(--axi-ink-line);
815
+ stroke-width: var(--axi-border-control);
816
+ }
817
+ .axi-chart .recharts-bar-background-rectangle { fill: var(--axi-well-fill); }
818
+
819
+ .axi-chart .recharts-reference-line-line {
820
+ stroke: var(--axi-text-faint);
821
+ stroke-width: var(--axi-border-hairline);
822
+ stroke-dasharray: none;
823
+ }
824
+
825
+ /* The brush: a well, a slide, two travellers, and the preview curve dropped to
826
+ furniture weight because it is context rather than data you read off. */
827
+ .axi-chart .recharts-brush > rect:first-of-type {
828
+ fill: var(--axi-well-fill);
829
+ stroke: var(--axi-well-line);
830
+ stroke-width: var(--axi-border-control);
831
+ }
832
+ .axi-chart .recharts-brush-slide {
833
+ fill: var(--axi-surface-paint);
834
+ fill-opacity: 1;
835
+ }
836
+ .axi-chart .recharts-brush-traveller > rect {
837
+ fill: var(--axi-accent);
838
+ stroke: var(--axi-ink-line);
839
+ stroke-width: var(--axi-border-hairline);
840
+ }
841
+ .axi-chart .recharts-brush-traveller > line { stroke: var(--axi-accent-ink); }
842
+ .axi-chart .recharts-brush-texts { fill: var(--axi-text-faint); }
843
+ .axi-chart .recharts-brush .recharts-curve {
844
+ stroke: var(--axi-text-faint);
845
+ fill: none;
846
+ }