@axiapps/axi-design 1.42.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 —
@@ -501,6 +586,48 @@ that must be rejected, one per prohibition. The rule above is therefore a check
501
586
  rather than a promise — the same treatment rule 3's weights get in
502
587
  `tests/tokens.test.mjs`.
503
588
 
589
+ ## 13. A state is an attribute, and the appearance follows it
590
+
591
+ Every state this language draws is keyed off an attribute the element already
592
+ carries, never off a class invented to describe the look. `[aria-current]` on a
593
+ rail item, a tab, a table row and a picked panel. `[aria-pressed="true"]` on a
594
+ pill. `[aria-selected="true"]` on a listbox option. `[aria-disabled="true"]`
595
+ beside `:disabled`, in 26 places. `[aria-sort]` on the sorted column,
596
+ `[aria-expanded]` on the thing that opens. One spelling per semantics, and the
597
+ semantics decides which — not the appearance, which is why a rail item and a
598
+ picked panel share `[aria-current]` while looking nothing alike.
599
+
600
+ The reason is not tidiness. **A state that exists only as an appearance is not a
601
+ state.** A consumer that marks the chosen card with a class has drawn a mark
602
+ sighted users can see and told everyone else nothing, and no amount of styling
603
+ fixes it from our side — the information was never in the document. Keying the
604
+ style off the attribute makes the two inseparable: you cannot get the look
605
+ without emitting the state, and you cannot emit the state and fail to get the
606
+ look.
607
+
608
+ This is also what stops the language growing a second vocabulary. An invented
609
+ `--selected` modifier would be a synonym for `[aria-current]` that a screen
610
+ reader cannot read, and the two would drift the first time one of them got a
611
+ tweak. There is no `.axi-panel--selected` for the same reason there is no
612
+ `.axi-btn--off`.
613
+
614
+ Two consequences when adding a component:
615
+
616
+ - **Find the attribute before writing the rule.** If the state the component
617
+ needs already has an ARIA spelling, use it, even if the look is unlike every
618
+ other user of that attribute. If it genuinely has none, that is the moment to
619
+ ask whether the state is real.
620
+ - **A state the markup holds needs no attribute at all.** A `<label>` wrapping
621
+ its own radio is the correct markup for a picker; the input holds the state,
622
+ so the label has nothing to set, and copying it onto the label would be a
623
+ second source of truth that can disagree with the first. That case is matched
624
+ structurally — `:has(> input:checked)`, the language's only `:has()`, with the
625
+ child combinator load-bearing: a descendant match would fire on any checkbox
626
+ buried in the component's content.
627
+
628
+ `tests/tokens.test.mjs` holds this rule to the components that carry it, so it
629
+ is a check rather than a promise.
630
+
504
631
  ## Tokens
505
632
 
506
633
  Three layers, in `src/tokens.css` — the only file permitted to contain a colour
@@ -548,6 +675,7 @@ instead of joining it.
548
675
  | Table corner | 3 | where the two cross |
549
676
  | Sticky chrome | 40 | `.axi-mast` |
550
677
  | Popovers | 41 | `.axi-menu__pop`, `.axi-picker__pop` |
678
+ | Sheet scrim | 44 | `.axi-scrim--sheet` |
551
679
  | Sheet | 45 | `.axi-sheet` |
552
680
  | Scrim | 50 | `.axi-scrim` |
553
681
  | Drawer | 51 | `.axi-drawer` |
@@ -572,6 +700,15 @@ A negative `z-index` inside a component's own `isolation` context — the sigil'
572
700
  backing shape — is not a layer and is not listed. It is invisible outside the
573
701
  component that owns it.
574
702
 
703
+ The scrim appears twice, and that is the table saying something rather than
704
+ repeating itself. A scrim's rung is not a property of the scrim; it is "directly
705
+ below the thing I dismiss", so a language with two dismissible surfaces at two
706
+ rungs has two scrims. Reading the single 50 as the scrim's own number is what
707
+ makes a sheet impossible to scrim: at 50 over the sheet's 45 the scrim covers
708
+ the sheet completely, every click lands on the dismiss handler, and the surface
709
+ opens dead. So when you add a dismissible surface, check whether it needs a
710
+ scrim rung directly beneath it, and add both rows together.
711
+
575
712
  ## Light mode
576
713
 
577
714
  Not shipped. The system is *structured* for it: no component contains a colour
@@ -684,6 +821,44 @@ The line to hold is the one-for-one rule above, not a list of layers. A theme
684
821
  that restates the block still paints every component; a theme that invents one
685
822
  does not.
686
823
 
824
+ ### A change to the language is not finished until every theme wears it
825
+
826
+ The one-for-one rule above is written as an obligation on a *theme* — here is
827
+ what a new theme owes the language. Read only that way it has a hole in it, and
828
+ the hole is every change that goes the other direction. A token added to
829
+ `tokens.css`, a component added to `src/`, a look retuned: each of those is a
830
+ change to the thing the themes are mirroring, and none of them is finished when
831
+ the main theme looks right. There are three themes — the language itself in
832
+ `src/tokens.css`, `flat`, and `glass` — and a change lands in all three or it
833
+ has not landed.
834
+
835
+ That is the symmetric half of the toll already stated above. **A new theme
836
+ capability costs a main-theme token first; a change to the main theme costs
837
+ every theme a look.** Neither direction is optional, and the second is the one
838
+ easy to skip, because the default theme is the one on screen while you work.
839
+
840
+ What "answered in every theme" means depends on the shape of the change:
841
+
842
+ - **A new token.** Every theme either restates it or can point at why it does
843
+ not need to. Two reasons count. The default is inert — `--axi-surface-filter`
844
+ and `--axi-ground-image` are `none`, so a theme that wants neither is already
845
+ correct. Or the token aliases one the theme did restate —
846
+ `--axi-surface-float: var(--axi-surface)`, so `flat` restating the surface
847
+ restates the float with it. A token holding a literal of its own is answered
848
+ by neither of those, and every theme has to say it. This is checked; see
849
+ below.
850
+ - **A new component.** It renders under all three, and you look at it under all
851
+ three. The gallery's theme switcher is there for exactly the reason the accent
852
+ switcher is: a component that hard-coded something looks fine until you
853
+ change the thing it hard-coded. A panel that reads as a panel on opaque slate
854
+ can vanish on a translucent one.
855
+ - **A retuned look.** The `-paint` companions are the case that made this a
856
+ section. Lifting them meant every surface a theme grades needs a flat
857
+ companion beside it, and both themes had to be edited in the same commit as
858
+ the tokens — edit one and the other hands a gradient straight to
859
+ `background-color`, which is not a subtle failure but it is an invisible one
860
+ from the theme you happened to be looking at.
861
+
687
862
  **What is mechanically enforced.** `tests/themes.test.mjs` reads every
688
863
  `dist/themes/*.css` and checks the mirror rather than trusting it: the file
689
864
  contains exactly one rule, its selector is `[data-axi-theme="<id>"]` for the
@@ -693,6 +868,16 @@ non-empty value, and every property it declares is already declared in
693
868
  takes a second selector. An invented token fails the last. The suite passes
694
869
  vacuously while no theme exists, and binds the moment the first file lands.
695
870
 
871
+ The section above is checked from the other side by the same file: a token that
872
+ *any* theme restates must be restated by *every* theme, unless that theme
873
+ inherits an answer already — the main-theme default is inert, or the token
874
+ aliases another the theme did restate, and the check follows the alias chain
875
+ rather than taking the two reasons on trust. So a token one theme has an opinion
876
+ about cannot be a token another theme forgot. What no test can check is the
877
+ third bullet, the look you did not look at: a component can paint under all
878
+ three themes and still be wrong under two of them, and the only instrument for
879
+ that is the theme switcher in the gallery.
880
+
696
881
  ## Adding a component
697
882
 
698
883
  1. Which rule justifies it? If none, write the rule first or stop.
@@ -702,7 +887,11 @@ vacuously while no theme exists, and binds the moment the first file lands.
702
887
  [Themes](#themes).
703
888
  4. Add it to the gallery, and check it with the accent switcher — if it does
704
889
  not follow the accent, it hard-coded something.
705
- 5. `npm run build` and commit `dist/axi.css` with your source change.
890
+ 5. Then check it with the theme switcher, under all three — the language,
891
+ `flat` and `glass`. Translucent surfaces and a 16px corner break different
892
+ things than opaque ones do, and a component is not done until it reads right
893
+ under each. See [Themes](#themes).
894
+ 6. `npm run build` and commit `dist/axi.css` with your source change.
706
895
 
707
896
  ### A style only reachable through a layer will be re-invented
708
897
 
@@ -733,10 +922,30 @@ allowed to come apart, they had already come apart. When you lift a
733
922
  layer-scoped style out, look for the declarations the layer was getting for
734
923
  free from its element. Those are the ones the new spelling silently loses.
735
924
 
736
- Two instances is a pattern, so the check belongs at the top of the list when
737
- adding anything: grep `src/` for the component's style living behind a layer
738
- prefix. If it does, it has consumers you cannot see, and they have already
739
- drawn their own.
925
+ The third instance was not behind a layer prefix at all, and it widens the
926
+ rule. `.axi-palette__list` hid its scrollbar with the argument written inline —
927
+ a bar down the side of a small panel reporting a fact the row count has already
928
+ given. That was the language's only word for the decision, and the decision was
929
+ about *strips*, not about palettes. The same consumer needed it for two rails
930
+ and a picker slot and could not reach it, so it wrote the rule again five times
931
+ in its own stylesheet — twice with a `*` descendant arm, because the element
932
+ that actually scrolls sits one level inside a component it does not control,
933
+ which is the sledgehammer a consumer reaches for when the language gives it no
934
+ name. The remedy is unchanged: one rule, every spelling in it, and a class the
935
+ consumer can spend (`.axi-scroll-quiet`, in `src/utilities.css`).
936
+
937
+ So the check is not only "is this style behind a layer prefix". It is **is this
938
+ style the only statement of a decision that is broader than the component
939
+ stating it**. A component's own inline reasoning is the tell: if the comment
940
+ argues about a category of thing — a strip, a recess, a reading — and the
941
+ selector names one member of that category, the rest of the category has
942
+ nowhere to look.
943
+
944
+ Three instances, so the check belongs at the top of the list when adding
945
+ anything: grep `src/` for the component's style living behind a layer prefix,
946
+ and read the comments on any single-selector rule you are about to copy. If
947
+ either applies, it has consumers you cannot see, and they have already drawn
948
+ their own.
740
949
 
741
950
  ### A refusal holds at every level, not just the one it was written for
742
951
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/axi-design",
3
- "version": "1.42.0",
3
+ "version": "1.44.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/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,31 @@
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
+ }
package/src/layout.css CHANGED
@@ -37,6 +37,68 @@
37
37
  /* A horizontal run that wraps rather than overflowing. */
38
38
  .axi-row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--axi-row-gap, 10px); }
39
39
 
40
+ /* ---------- split pane ---------- */
41
+ /* A picker on the left choosing what the surface on the right shows. Thirteen
42
+ sections of one consumer are this shape, and a fourteenth had hand-built it
43
+ inline because there was no name for it.
44
+
45
+ It is two objects on one plane and not two panels, which is the whole reason
46
+ it needs naming: a picker list beside a table is the first case --axi-well's
47
+ own comment names, and the thing the list picks is the raised counterpart to
48
+ that recess. Drawn as two panels it read flat - a hairline parting one field,
49
+ with the list's scrollbar carving a groove down the middle of it - because
50
+ nothing in it was an object.
51
+
52
+ The body carries the CONTROL step, not the panel's. A split pane lives
53
+ inside a panel that has already paid a 6px block, and rule 8's counterpart
54
+ settles what a second one nested in it reads as: two planes arguing. The
55
+ consumer that derived this shape by hand got the outline and left out the
56
+ block entirely, which is a rule 3 failure in the other direction - a
57
+ boundary drawn around content that is standing on the surface behind it.
58
+ Outlined and blocked, at the weight that says "the contents of a box".
59
+
60
+ The radius follows the form step and not the footprint. The body is
61
+ panel-sized in area and control-weight in form, and .axi-panel--tile already
62
+ settled that argument the same way.
63
+
64
+ `minmax(0, 1fr)` rather than `1fr`: a table in a `1fr` track cannot shrink
65
+ below its own content, and every cell in .axi-table is `nowrap`, so the
66
+ column blows out and takes the pane's width with it. `1fr` is
67
+ `minmax(auto, 1fr)` and `auto` is a content floor.
68
+
69
+ No breakpoint of its own. Under 640px the two stack, which is layout.css's
70
+ one breakpoint and deliberately still its only one - .axi-table--fixed
71
+ already makes a 360px pane work, so a pane narrow enough to need a second
72
+ breakpoint is one --fixed was written for. */
73
+ .axi-split {
74
+ display: grid;
75
+ grid-template-columns: var(--axi-split-nav-w, 280px) minmax(0, 1fr);
76
+ gap: var(--axi-split-gap, 10px);
77
+ /* A split pane is routinely the flex child that fills a section's height,
78
+ and a grid that cannot shrink below its content pushes the section past
79
+ its own cap - the same reason .axi-palette__list says it. */
80
+ min-height: 0;
81
+ }
82
+ /* The picker is a well, declared as one in the same rule as .axi-well in
83
+ src/primitives.css rather than restated here. This part only says what the
84
+ slot adds: it scrolls, and it may not out-grow the row it sits in. Its
85
+ scrollbar is hidden by the language's quiet-scroll rule in
86
+ src/utilities.css, which is where that decision lives for every strip. */
87
+ .axi-split__nav { overflow-y: auto; min-height: 0; }
88
+ .axi-split__body {
89
+ /* Paired with the track's minmax(0, 1fr): the track stops the COLUMN
90
+ growing, this stops the ITEM, whose min-width is `auto` by default and is
91
+ what an ellipsis inside it is actually fighting. */
92
+ min-width: 0;
93
+ min-height: 0;
94
+ overflow: hidden;
95
+ background: var(--axi-surface);
96
+ backdrop-filter: var(--axi-surface-filter);
97
+ border: var(--axi-border-control) solid var(--axi-ink-line);
98
+ border-radius: var(--axi-radius-sm);
99
+ box-shadow: var(--axi-shadow-control);
100
+ }
101
+
40
102
  @media (max-width: 640px) {
41
103
  /* The fallback must match the resting rule's (var(--axi-gutter), 18px) -
42
104
  README.md documents --axi-page-pad's fallback as --axi-gutter, and a
@@ -44,4 +106,10 @@
44
106
  mobile, which is the one viewport where the gutter matters most. */
45
107
  .axi-page { padding-inline: var(--axi-page-pad, var(--axi-gutter)); }
46
108
  .axi-grid { grid-template-columns: 1fr; }
109
+ /* The picker stops being a column beside the thing it picks and becomes the
110
+ row above it. It keeps a height cap - a picker that grows to twenty rows
111
+ pushes the result it picked off the screen, which is the one thing the
112
+ stacked form must not do. */
113
+ .axi-split { grid-template-columns: 1fr; }
114
+ .axi-split__nav { max-height: var(--axi-split-nav-h, 200px); }
47
115
  }