@axiapps/axi-design 1.9.0 → 1.11.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
@@ -24,6 +24,15 @@
24
24
  --axi-text-dim: #a7b0be;
25
25
  --axi-text-faint: #7c8695;
26
26
  --axi-scrim: rgba(6, 7, 9, .72); /* behind a drawer */
27
+ /* What a surface does to whatever is behind it. `none` here, and `none` is
28
+ genuinely inert - it paints nothing and, unlike a transform, establishes
29
+ no containing block, so the fixed-descendant trap rule 4 documents stays
30
+ closed. It exists so a theme has somewhere to put the answer: a glass
31
+ theme makes the surfaces translucent and restates this token, and the
32
+ blur arrives on every surface at once without one component learning the
33
+ word. This is the toll the Themes section describes - a theme cannot
34
+ invent a capability, so the capability is declared here first, inert. */
35
+ --axi-surface-filter: none;
27
36
 
28
37
  /* --- accent & status --- */
29
38
  --axi-accent: #ffc53d;
@@ -31,6 +40,13 @@
31
40
  enough to pass contrast on this ground; an app choosing a dark accent
32
41
  overrides this to --axi-text rather than editing components. */
33
42
  --axi-accent-ink: var(--axi-ink-line);
43
+ /* Text drawn on top of a STATUS fill (an ok/warn/danger chip, a close
44
+ button gone red). Identical to --axi-ink-line today, which is precisely
45
+ why it has to exist: components were spelling this "the outline colour"
46
+ and it happened to read correctly. A theme that makes the outline light
47
+ would otherwise put light text on a bright fill and lose the word. Same
48
+ idea as --axi-accent-ink above, for the fills that are not the accent. */
49
+ --axi-ink-on-fill: var(--axi-ink-line);
34
50
  --axi-meta: #4ec3ff; /* the one cool ink: meta only, never a status */
35
51
  --axi-ok: #2fd38a;
36
52
  --axi-warn: #ff7a2f;
@@ -49,6 +65,15 @@
49
65
  block instead of admitting bare literals. */
50
66
  --axi-offset-panel-hover: 10px;
51
67
  --axi-offset-control-hover: 6px;
68
+ /* The block itself, composed once. A component asks for "the panel block",
69
+ not for an offset it then has to assemble into a shadow correctly four
70
+ times per file - and a theme gets one place to restate what raising a
71
+ thing looks like. The shape is still rule 3's: two equal offsets, zero
72
+ blur, the outline ink. */
73
+ --axi-shadow-panel: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
74
+ --axi-shadow-control: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
75
+ --axi-shadow-panel-hover: var(--axi-offset-panel-hover) var(--axi-offset-panel-hover) 0 var(--axi-ink-line);
76
+ --axi-shadow-control-hover: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
52
77
  /* The form is sharp. A hard outline, a hard block and a rounded corner are
53
78
  three decisions, and the rounded one softens the other two: the corner is
54
79
  the first place a flat outlined shape can start looking like a button in
@@ -157,6 +182,8 @@ a { color: inherit; }
157
182
  .axi-pill:hover,
158
183
  .axi-pill[aria-pressed="true"]:hover,
159
184
  .axi-select:hover,
185
+ .axi-picker__btn:hover,
186
+ .axi-picker__btn[aria-expanded="true"],
160
187
  .axi-card:hover,
161
188
  .axi-drawer__close:hover {
162
189
  transform: none !important;
@@ -176,9 +203,19 @@ a { color: inherit; }
176
203
  us to ship a component for it. */
177
204
  .axi-panel {
178
205
  background: var(--axi-surface);
206
+ /* What the surface does to whatever is behind it. `none` in the main theme,
207
+ which is inert: unlike a transform, `backdrop-filter: none` establishes no
208
+ containing block, so rule 4's fixed-descendant trap is not reopened here.
209
+ A theme that makes the surfaces translucent restates this token and the
210
+ blur arrives everywhere at once. Carried only by the surfaces something
211
+ can actually be behind - a panel, a popover, an overlay, the scrim. A
212
+ chip, a switch, a page number and a card glyph sit directly on their
213
+ parent's fill with nothing but that fill behind them, so the filter would
214
+ cost a compositor layer each to blur a flat colour. */
215
+ backdrop-filter: var(--axi-surface-filter);
179
216
  border: var(--axi-border-panel) solid var(--axi-ink-line);
180
217
  border-radius: var(--axi-radius);
181
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
218
+ box-shadow: var(--axi-shadow-panel);
182
219
  /* A panel owns its padding so every consumer doesn't have to invent one;
183
220
  override per instance with --axi-panel-pad where a tighter or looser
184
221
  fit is genuinely called for. */
@@ -205,13 +242,13 @@ a { color: inherit; }
205
242
  vision and costs no colour. */
206
243
  .axi-btn:hover {
207
244
  color: var(--axi-text);
208
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
245
+ box-shadow: var(--axi-shadow-control);
209
246
  transform: translate(-2px, -2px);
210
247
  }
211
248
  .axi-btn--primary {
212
249
  background: var(--axi-accent);
213
250
  color: var(--axi-accent-ink);
214
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
251
+ box-shadow: var(--axi-shadow-control);
215
252
  }
216
253
  /* The one button that rests with a block already under it, so the generic
217
254
  hover's translate alone would move element and block together and leave the
@@ -219,7 +256,7 @@ a { color: inherit; }
219
256
  rule 4 warns about. It lifts the way a panel does: the block deepens too. */
220
257
  .axi-btn--primary:hover {
221
258
  color: var(--axi-accent-ink);
222
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
259
+ box-shadow: var(--axi-shadow-control-hover);
223
260
  }
224
261
  .axi-btn--ghost { background: transparent; }
225
262
  .axi-btn--dashed { background: transparent; border-style: dashed; }
@@ -245,13 +282,13 @@ a { color: inherit; }
245
282
  }
246
283
  .axi-pill:hover {
247
284
  color: var(--axi-text);
248
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
285
+ box-shadow: var(--axi-shadow-control);
249
286
  transform: translate(-2px, -2px);
250
287
  }
251
288
  .axi-pill[aria-pressed="true"] {
252
289
  background: var(--axi-pill-fill, var(--axi-accent));
253
290
  color: var(--axi-accent-ink);
254
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
291
+ box-shadow: var(--axi-shadow-control);
255
292
  }
256
293
  /* Spelled out rather than left to source order: `.axi-pill:hover` and
257
294
  `.axi-pill[aria-pressed="true"]` have identical specificity, so without this
@@ -261,7 +298,7 @@ a { color: inherit; }
261
298
  .axi-pill[aria-pressed="true"]:hover {
262
299
  background: var(--axi-pill-fill, var(--axi-accent));
263
300
  color: var(--axi-accent-ink);
264
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
301
+ box-shadow: var(--axi-shadow-control-hover);
265
302
  transform: translate(-2px, -2px);
266
303
  }
267
304
 
@@ -335,9 +372,9 @@ a { color: inherit; }
335
372
  letter-spacing: var(--axi-ls-micro);
336
373
  text-transform: uppercase;
337
374
  }
338
- .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-line); }
339
- .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-line); }
340
- .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-line); }
375
+ .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-on-fill); }
376
+ .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-on-fill); }
377
+ .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
341
378
  .axi-chip--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
342
379
  .axi-chip--meta {
343
380
  background: transparent;
@@ -400,8 +437,11 @@ a { color: inherit; }
400
437
  /* ---------- select ---------- */
401
438
  /* The closed box is ours everywhere: strip the native control and draw the
402
439
  caret, so a select sits alongside the other controls as just another
403
- outlined chip instead of announcing the OS. */
404
- .axi-select {
440
+ outlined chip instead of announcing the OS. .axi-picker__btn is the same
441
+ box worn by a button instead of a <select>; the two share every
442
+ declaration here so a dropdown reads the same whichever half opens it. */
443
+ .axi-select,
444
+ .axi-picker__btn {
405
445
  appearance: none;
406
446
  padding: 10px 30px 10px 9px;
407
447
  border: var(--axi-border-control) solid var(--axi-ink-line);
@@ -422,16 +462,24 @@ a { color: inherit; }
422
462
  background-repeat: no-repeat;
423
463
  transition: transform .1s, box-shadow .1s;
424
464
  }
425
- .axi-select:hover {
465
+ /* The open trigger keeps the lift. Rule 4 gives the raise to a pointer, but a
466
+ disclosure that drops back flat the moment the pointer moves into the list
467
+ it opened severs the two: the lift is what says this box and that popover
468
+ are one control. */
469
+ .axi-select:hover,
470
+ .axi-picker__btn:hover,
471
+ .axi-picker__btn[aria-expanded='true'] {
426
472
  color: var(--axi-text);
427
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
473
+ box-shadow: var(--axi-shadow-control);
428
474
  transform: translate(-2px, -2px);
429
475
  }
430
476
 
431
477
  /* The popup stays OS chrome until a browser lets us style it. Where one does
432
478
  (Chromium's base-select), the list is drawn with the same outline and offset
433
479
  block as our own popovers, so both dropdown kinds read as one family; where
434
- it does not, the closed box above is still ours and the list is native. */
480
+ it does not, the closed box above is still ours and the list is native -
481
+ and if that native list is not good enough, .axi-picker below draws the
482
+ whole thing. */
435
483
  @supports (appearance: base-select) {
436
484
  /* base-select draws its own ::picker-icon, so the hand-drawn caret above
437
485
  would be a second arrow. */
@@ -443,7 +491,8 @@ a { color: inherit; }
443
491
  border: var(--axi-border-panel) solid var(--axi-ink-line);
444
492
  border-radius: var(--axi-radius);
445
493
  background: var(--axi-surface-raised);
446
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
494
+ backdrop-filter: var(--axi-surface-filter);
495
+ box-shadow: var(--axi-shadow-panel);
447
496
  }
448
497
  .axi-select option {
449
498
  display: flex; align-items: center; gap: 9px;
@@ -466,6 +515,221 @@ a { color: inherit; }
466
515
  .axi-select option::checkmark { content: "\2713"; color: var(--axi-accent); font-weight: 900; }
467
516
  }
468
517
 
518
+ /* ---------- picker ---------- */
519
+ /* The select's other half. Above, the native popup is left as OS chrome
520
+ wherever `appearance: base-select` is missing - and that is most places
521
+ today, including every Electron built on a Chromium older than the
522
+ property. What lands there is a raised list the language cannot reach: no
523
+ ink outline, no offset block, its own selection colour instead of the
524
+ accent. That is rule 3 broken by a box we do not own, so the fix is to stop
525
+ asking the OS to draw it. .axi-picker is a button and a popover wearing the
526
+ closed box and the list styling from the @supports branch above, so the two
527
+ kinds are the same dropdown and a consumer picks by what the platform has.
528
+
529
+ Prefer the native <select> where it works: it comes with keyboard handling,
530
+ typeahead, and a popup that can escape the window. This is what you use
531
+ when it does not.
532
+
533
+ Markup contract - the listbox pattern, and the popover carries the panel
534
+ weight because it is a raised surface, not a control:
535
+
536
+ <div class="axi-picker">
537
+ <button class="axi-picker__btn" aria-haspopup="listbox"
538
+ aria-expanded="false" aria-controls="months">Jul 2026</button>
539
+ <div class="axi-picker__pop" id="months" role="listbox" hidden>
540
+ <button class="axi-picker__opt" role="option" aria-selected="true">
541
+ Jul 2026
542
+ </button>
543
+ </div>
544
+ </div>
545
+
546
+ Like the menu and the tooltip, the language ships no script: `hidden`,
547
+ `aria-expanded` and `aria-selected` are the whole state, and the consumer
548
+ toggles them. gallery.js is the reference wiring, arrow keys and Escape
549
+ included. */
550
+ .axi-picker { position: relative; display: inline-flex; }
551
+ .axi-picker__btn {
552
+ display: inline-flex; align-items: center;
553
+ width: 100%;
554
+ text-align: left;
555
+ }
556
+ .axi-picker__pop {
557
+ /* Above .axi-mast's z-index: 40, for the same reason .axi-menu__pop is. */
558
+ position: absolute; top: calc(100% + 9px); left: 0; z-index: 41;
559
+ /* Never narrower than the box it came out of, and no wider than its
560
+ longest row needs. A list that shrinks to the text is a list that has
561
+ moved, and the eye has to find the column again. */
562
+ min-width: 100%;
563
+ max-height: 340px; overflow-y: auto;
564
+ padding: 6px;
565
+ background: var(--axi-surface-raised);
566
+ backdrop-filter: var(--axi-surface-filter);
567
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
568
+ border-radius: var(--axi-radius);
569
+ /* The block falls outside the popover's own box. An ancestor that clips -
570
+ a scrolling pane, a panel with overflow: hidden - eats it, and the only
571
+ shadow on the screen is the one that goes missing. */
572
+ box-shadow: var(--axi-shadow-panel);
573
+ }
574
+ .axi-picker__pop[hidden] { display: none; }
575
+ /* A popover cannot always live beside its trigger. Inside a pane that scrolls
576
+ or a panel that clips, `position: absolute` puts the list where the
577
+ overflow can eat it - and the offset block falls outside the popover's box,
578
+ so the block is the first thing to go. The way out is the tooltip's
579
+ contract: append the popover to <body> and set left/top from script, having
580
+ measured the trigger. The box is unchanged; only who positions it moves. */
581
+ .axi-picker__pop--fixed { position: fixed; }
582
+ .axi-picker__opt {
583
+ display: flex; align-items: center; gap: 9px;
584
+ width: 100%;
585
+ padding: 8px 9px;
586
+ border: 0;
587
+ border-radius: var(--axi-radius-sm);
588
+ background: transparent;
589
+ color: var(--axi-text-dim);
590
+ font: var(--axi-t-label);
591
+ font-size: 12.5px;
592
+ letter-spacing: var(--axi-ls-label);
593
+ text-transform: uppercase;
594
+ text-align: left;
595
+ cursor: pointer;
596
+ }
597
+ /* The tick is in every row, inked only in the chosen one. Give it to the
598
+ selected row alone and every label shifts by its width as the choice moves,
599
+ which turns picking an option into the list twitching. */
600
+ .axi-picker__opt::before {
601
+ content: "\2713";
602
+ color: transparent;
603
+ font-weight: 900;
604
+ }
605
+ .axi-picker__opt:hover, .axi-picker__opt:focus {
606
+ background: var(--axi-ground); color: var(--axi-text);
607
+ }
608
+ /* The page-wide focus ring sits 2px outside its element; inside a popover
609
+ this tight that is 2px into the neighbouring row, so pull it back in. */
610
+ .axi-picker__opt:focus-visible { outline-offset: -3px; }
611
+ .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
612
+ .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }
613
+
614
+ /* ---------- avatar ---------- */
615
+ /* A square, because a circle is the one shape this language does not have:
616
+ --axi-radius is 0, and a round avatar would be the only rounded thing on
617
+ the page. It follows the radius scale like everything else, so a consumer
618
+ who wants round avatars sets --axi-radius-sm and gets rounded controls
619
+ everywhere - which is the honest version of the request.
620
+ Flat: outlined, no block. An avatar is content inside a panel, the same as
621
+ .axi-stat and .axi-table__rank, not a thing raised off it. */
622
+ .axi-avatar {
623
+ width: var(--axi-avatar-size, 40px);
624
+ height: var(--axi-avatar-size, 40px);
625
+ flex: none;
626
+ display: grid;
627
+ place-items: center;
628
+ overflow: hidden;
629
+ background: var(--axi-ground);
630
+ color: var(--axi-text-dim);
631
+ border: var(--axi-border-control) solid var(--axi-ink-line);
632
+ border-radius: var(--axi-radius-sm);
633
+ font: var(--axi-t-label);
634
+ letter-spacing: var(--axi-ls-label);
635
+ text-transform: uppercase;
636
+ }
637
+ .axi-avatar--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
638
+ /* object-fit rather than a background-image, so the <img>'s alt text survives
639
+ and a broken source is visible rather than silently blank. */
640
+ .axi-avatar__img { width: 100%; height: 100%; object-fit: cover; display: block; }
641
+
642
+ /* --- forms.css --- */
643
+ /* axi design language - forms.
644
+ Real inputs, styled. Every control here is an actual <input> or <textarea>
645
+ with `appearance: none`, never a <div> wearing a class: a checkbox that is
646
+ not an <input> does not submit with its form, does not toggle from the
647
+ keyboard, and is not announced as a checkbox. The language styles controls;
648
+ it does not reimplement them.
649
+
650
+ Rule 3's flat case governs the geometry: a control inside a panel is
651
+ outlined and carries no block, exactly as .axi-input and .axi-switch
652
+ already are. Rule 5 governs the state: the fill is what says "on".
653
+
654
+ The focus ring is deliberately absent from this file. base.css draws one
655
+ page-wide on :focus-visible, and appearance: none does not remove it - it
656
+ removes the browser's own. Suppressing the outline anywhere here would make
657
+ every control in it unusable by keyboard. */
658
+
659
+ /* ---------- checkbox and radio ---------- */
660
+ /* One box, two marks. --axi-radius is 0, so both are square and a round radio
661
+ would be the only rounded shape in the language - the first place a flat
662
+ outlined form starts looking like some other framework's. So they are told
663
+ apart by their mark instead: the checkbox gets a check, the radio gets the
664
+ family diamond (rule 7).
665
+
666
+ Both read the same two knobs on purpose. A form holding checkboxes and
667
+ radios together should not need two properties set to the same value to
668
+ keep its controls the same size. */
669
+ .axi-check,
670
+ .axi-radio {
671
+ appearance: none;
672
+ -webkit-appearance: none;
673
+ margin: 0;
674
+ flex: none;
675
+ width: var(--axi-check-size, 22px);
676
+ height: var(--axi-check-size, 22px);
677
+ display: inline-grid;
678
+ place-items: center;
679
+ background: var(--axi-ground);
680
+ border: var(--axi-border-control) solid var(--axi-ink-line);
681
+ border-radius: var(--axi-radius-sm);
682
+ cursor: pointer;
683
+ }
684
+ /* The mark is always in the DOM and revealed with opacity rather than being
685
+ created on :checked, so the box never changes size as it toggles. */
686
+ .axi-check::after {
687
+ content: "";
688
+ width: 55%;
689
+ height: 30%;
690
+ opacity: 0;
691
+ border-left: var(--axi-border-control) solid var(--axi-accent-ink);
692
+ border-bottom: var(--axi-border-control) solid var(--axi-accent-ink);
693
+ transform: translateY(-12%) rotate(-45deg);
694
+ }
695
+ .axi-check:checked { background: var(--axi-check-fill, var(--axi-accent)); }
696
+ .axi-check:checked::after { opacity: 1; }
697
+ /* The radio's mark is the diamond itself, so it is drawn in the fill rather
698
+ than on it - there is no filled box underneath it to sit on. It takes the
699
+ hairline outline rule 7's diamond wears at this scale (see the prose
700
+ bullet, src/prose.css, the other place a diamond this small is drawn) -
701
+ the mark is revealed with opacity, so the hairline is hidden along with
702
+ the fill in the unchecked state and never leaks out on its own. */
703
+ .axi-radio::after {
704
+ content: "";
705
+ width: 46%;
706
+ height: 46%;
707
+ opacity: 0;
708
+ background: var(--axi-check-fill, var(--axi-accent));
709
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
710
+ transform: rotate(45deg);
711
+ }
712
+ .axi-radio:checked::after { opacity: 1; }
713
+ .axi-check:disabled,
714
+ .axi-radio:disabled { cursor: not-allowed; opacity: .5; }
715
+
716
+ /* ---------- textarea ---------- */
717
+ /* Not a component: the same .axi-input a single-line field uses, with the two
718
+ things a <textarea> needs that an <input> does not. The font is restated
719
+ because a textarea does not inherit the page font in any browser, and a
720
+ form whose notes field is monospace while its name field is not is a bug
721
+ nobody files and everybody sees.
722
+
723
+ Resizing is vertical only. A textarea dragged wider than its field breaks
724
+ the column its form is laid out in, and the reader who did it has no way
725
+ to discover that the layout is not at fault. */
726
+ textarea.axi-input {
727
+ min-height: var(--axi-textarea-h, 90px);
728
+ resize: vertical;
729
+ font-family: var(--axi-sans);
730
+ line-height: 1.5;
731
+ }
732
+
469
733
  /* --- layout.css --- */
470
734
  /* axi design language - layout.
471
735
  Three measures, one grid, two spacing helpers. Deliberately small: a
@@ -581,16 +845,17 @@ a { color: inherit; }
581
845
  background: var(--axi-accent);
582
846
  color: var(--axi-accent-ink);
583
847
  border-color: var(--axi-ink-line);
584
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
848
+ box-shadow: var(--axi-shadow-control);
585
849
  }
586
850
 
587
851
  /* ---------- notice ---------- */
588
852
  .axi-notice {
589
853
  display: flex; gap: 14px; padding: 15px 18px;
590
854
  background: var(--axi-surface);
855
+ backdrop-filter: var(--axi-surface-filter);
591
856
  border: var(--axi-border-panel) solid var(--axi-ink-line);
592
857
  border-radius: var(--axi-radius);
593
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
858
+ box-shadow: var(--axi-shadow-panel);
594
859
  }
595
860
  .axi-notice__icon {
596
861
  flex: none; width: 26px; height: 26px;
@@ -608,9 +873,10 @@ a { color: inherit; }
608
873
  display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
609
874
  padding: 14px;
610
875
  background: var(--axi-surface);
876
+ backdrop-filter: var(--axi-surface-filter);
611
877
  border: var(--axi-border-panel) solid var(--axi-ink-line);
612
878
  border-radius: var(--axi-radius);
613
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
879
+ box-shadow: var(--axi-shadow-panel);
614
880
  }
615
881
 
616
882
  /* ---------- menu popover ---------- */
@@ -625,9 +891,10 @@ a { color: inherit; }
625
891
  width: var(--axi-menu-width, 310px);
626
892
  max-height: 340px; overflow-y: auto; padding: 8px;
627
893
  background: var(--axi-surface-raised);
894
+ backdrop-filter: var(--axi-surface-filter);
628
895
  border: var(--axi-border-panel) solid var(--axi-ink-line);
629
896
  border-radius: var(--axi-radius);
630
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
897
+ box-shadow: var(--axi-shadow-panel);
631
898
  }
632
899
  .axi-menu__pop[hidden] { display: none; }
633
900
  .axi-menu__pop label {
@@ -636,7 +903,7 @@ a { color: inherit; }
636
903
  font-size: 13px; font-weight: 600; color: var(--axi-text-dim); cursor: pointer;
637
904
  }
638
905
  .axi-menu__pop label:hover { background: var(--axi-ground); color: var(--axi-text); }
639
- .axi-menu__pop input { margin: 3px 0 0; accent-color: var(--axi-accent); }
906
+ .axi-menu__pop input { margin: 3px 0 0; }
640
907
 
641
908
  /* ---------- tooltip ---------- */
642
909
  /* One line of ink on the darkest surface in the language, so it reads over
@@ -649,7 +916,11 @@ a { color: inherit; }
649
916
  exists nowhere else, because a keyboard or touch user may never see it. */
650
917
  .axi-tooltip {
651
918
  position: fixed;
652
- z-index: 99999;
919
+ /* Layer 70: above the drawer and its scrim, above toasts, below a
920
+ showModal()-ed dialog's top layer - which it was below anyway, since the
921
+ top layer is above every z-index. 99999 was not a layer, it was an
922
+ argument with one. */
923
+ z-index: 70;
653
924
  padding: 5px 8px;
654
925
  background: var(--axi-ink-line);
655
926
  color: var(--axi-text);
@@ -667,15 +938,16 @@ a { color: inherit; }
667
938
  display: flex; flex-direction: column; gap: 11px;
668
939
  padding: 22px 16px 14px;
669
940
  background: var(--axi-surface);
941
+ backdrop-filter: var(--axi-surface-filter);
670
942
  border: var(--axi-border-panel) solid var(--axi-ink-line);
671
943
  border-radius: var(--axi-radius);
672
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
944
+ box-shadow: var(--axi-shadow-panel);
673
945
  font: inherit; color: inherit; text-decoration: none; cursor: pointer;
674
946
  transition: transform .1s, box-shadow .1s;
675
947
  }
676
948
  .axi-card:hover {
677
949
  transform: translate(-3px, -3px);
678
- box-shadow: var(--axi-offset-panel-hover) var(--axi-offset-panel-hover) 0 var(--axi-ink-line);
950
+ box-shadow: var(--axi-shadow-panel-hover);
679
951
  }
680
952
  /* The top strip is drawn from the card itself so a renderer does not have to
681
953
  emit an extra element for it. It is opt-in: a card only gets a strip when
@@ -723,12 +995,13 @@ a { color: inherit; }
723
995
  .axi-card:hover .axi-card__go { color: var(--axi-text); }
724
996
 
725
997
  /* ---------- drawer ---------- */
726
- .axi-scrim { position: fixed; inset: 0; z-index: 50; background: var(--axi-scrim); border: 0; }
998
+ .axi-scrim { position: fixed; inset: 0; z-index: 50; background: var(--axi-scrim); backdrop-filter: var(--axi-surface-filter); border: 0; }
727
999
  .axi-drawer {
728
1000
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 51;
729
1001
  width: min(var(--axi-drawer-width, 560px), 100vw);
730
1002
  display: flex; flex-direction: column;
731
1003
  background: var(--axi-surface);
1004
+ backdrop-filter: var(--axi-surface-filter);
732
1005
  border-left: var(--axi-border-panel) solid var(--axi-ink-line);
733
1006
  }
734
1007
  .axi-drawer[hidden], .axi-scrim[hidden] { display: none; }
@@ -754,7 +1027,7 @@ a { color: inherit; }
754
1027
  }
755
1028
  .axi-drawer__close:hover {
756
1029
  background: var(--axi-accent); color: var(--axi-accent-ink);
757
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1030
+ box-shadow: var(--axi-shadow-control);
758
1031
  transform: translate(-2px, -2px);
759
1032
  }
760
1033
  .axi-drawer__body { overflow-y: auto; padding: 18px 24px 30px; }
@@ -822,10 +1095,70 @@ a { color: inherit; }
822
1095
  .axi-titlebar__btns button:hover { background: var(--axi-surface); color: var(--axi-text); }
823
1096
  /* Close is the one destructive control in the chrome, and the one place a
824
1097
  status ink belongs in a titlebar. */
825
- .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-line); }
1098
+ .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
826
1099
  /* The scrolling region under the chrome. */
827
1100
  .axi-window__body { flex: 1; overflow-y: auto; }
828
1101
 
1102
+ /* ---------- breadcrumbs ---------- */
1103
+ /* A path, not a control strip. The separator is the family diamond (rule 7)
1104
+ drawn in the rule ink, so it reads as punctuation rather than as a third
1105
+ link the reader could click.
1106
+ The current crumb is NOT filled. Rule 5 reserves a fill for a state
1107
+ something is in - pressed, on, current-as-in-selected - and the last crumb
1108
+ is a position, not a control that is on. It takes the full text ink and the
1109
+ label weight instead. */
1110
+ .axi-crumbs {
1111
+ display: flex;
1112
+ align-items: center;
1113
+ gap: 10px;
1114
+ flex-wrap: wrap;
1115
+ font: var(--axi-t-small);
1116
+ }
1117
+ .axi-crumbs a { color: var(--axi-text-dim); text-decoration: none; }
1118
+ .axi-crumbs a:hover { color: var(--axi-accent); }
1119
+ /* 7px, matching the prose bullet (src/prose.css) exactly, with the same
1120
+ --axi-border-hairline outline: rule 7 calls the motif "a 45deg-rotated
1121
+ outlined square", and a bare fill with no border does not match it. The
1122
+ hairline, not one of the two form steps, is the established weight for a
1123
+ diamond this small - at 6px a 2px border on each side would leave only a
1124
+ 2px fill, too tight to read as a diamond at all. */
1125
+ .axi-crumbs__sep {
1126
+ width: 7px; height: 7px; flex: none;
1127
+ background: var(--axi-rule);
1128
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1129
+ transform: rotate(45deg);
1130
+ }
1131
+ .axi-crumbs [aria-current="page"] { color: var(--axi-text); font-weight: 800; }
1132
+
1133
+ /* ---------- pagination ---------- */
1134
+ /* The same idea .axi-tabs already expresses, applied to a row of pages: the
1135
+ current one is filled and blocked, because "this one is on" is what a fill
1136
+ means here. Written as its own component rather than as a tabs modifier
1137
+ because a pager sits under content and a tab strip sits in a titlebar, and
1138
+ the two disagree about everything except that treatment. */
1139
+ .axi-pages { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; }
1140
+ .axi-pages__n {
1141
+ min-width: 36px;
1142
+ padding: 8px 10px;
1143
+ text-align: center;
1144
+ font: var(--axi-t-label);
1145
+ letter-spacing: var(--axi-ls-label);
1146
+ color: var(--axi-text-dim);
1147
+ text-decoration: none;
1148
+ background: var(--axi-surface-raised);
1149
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1150
+ border-radius: var(--axi-radius-sm);
1151
+ }
1152
+ .axi-pages__n:hover { color: var(--axi-text); }
1153
+ .axi-pages__n[aria-current="page"] {
1154
+ background: var(--axi-accent);
1155
+ color: var(--axi-accent-ink);
1156
+ box-shadow: var(--axi-shadow-control);
1157
+ }
1158
+ /* The elided run between two page numbers. Not a control and never focusable:
1159
+ it is the only thing in the row a reader cannot go to. */
1160
+ .axi-pages__gap { color: var(--axi-text-faint); padding: 0 2px; }
1161
+
829
1162
  @media (max-width: 640px) {
830
1163
  .axi-mast__in { min-height: 0; padding: 10px 16px; gap: 12px; }
831
1164
  .axi-tabs { margin-left: 0; width: 100%; overflow-x: auto; }
@@ -835,13 +1168,140 @@ a { color: inherit; }
835
1168
  to the panel hover block, so a card on a hover-emulating touch device
836
1169
  jumped further than it does on the desktop it was tuned for. */
837
1170
  .axi-notice, .axi-toolbar, .axi-card {
838
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1171
+ box-shadow: var(--axi-shadow-control);
839
1172
  }
840
1173
  .axi-card:hover {
841
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
1174
+ box-shadow: var(--axi-shadow-control-hover);
842
1175
  }
843
1176
  }
844
1177
 
1178
+ /* ---------- modal ---------- */
1179
+ /* A real <dialog>. Focus trapping, Esc-to-close, inertness for everything
1180
+ behind it and promotion to the top layer all come from the browser, and
1181
+ none of them can be had from CSS - so the alternative was a <div> plus a
1182
+ focus trap this language has no script to write.
1183
+ That choice has one visible cost, and it is named here rather than left to
1184
+ be discovered: a <dialog> brings its own ::backdrop, so the modal styles
1185
+ that instead of reusing .axi-scrim. The two look identical and are not the
1186
+ same element. Reusing .axi-scrim would have meant giving up <dialog>.
1187
+ Panel weight and panel block: it is the most raised thing on the page. */
1188
+ .axi-modal {
1189
+ padding: 0;
1190
+ max-width: min(var(--axi-modal-width, 560px), calc(100vw - 32px));
1191
+ background: var(--axi-surface);
1192
+ backdrop-filter: var(--axi-surface-filter);
1193
+ color: var(--axi-text);
1194
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
1195
+ border-radius: var(--axi-radius);
1196
+ box-shadow: var(--axi-shadow-panel);
1197
+ }
1198
+ .axi-modal::backdrop { background: var(--axi-scrim); backdrop-filter: var(--axi-surface-filter); }
1199
+ /* The head and foot are divided from the body by rules, not by outlines: they
1200
+ are parts of one raised thing, and rule 8's reading applies - a line inside
1201
+ a raised surface separates its parts, it does not raise them. */
1202
+ .axi-modal__head {
1203
+ display: flex; align-items: center; gap: 12px;
1204
+ padding: 15px 18px;
1205
+ border-bottom: var(--axi-border-control) solid var(--axi-rule);
1206
+ }
1207
+ .axi-modal__head h2 { font: var(--axi-t-h3); letter-spacing: var(--axi-ls-h3); margin: 0; }
1208
+ .axi-modal__body { padding: 18px; font: var(--axi-t-small); color: var(--axi-text-dim); }
1209
+ .axi-modal__foot {
1210
+ display: flex; justify-content: flex-end; gap: 9px;
1211
+ padding: 15px 18px;
1212
+ border-top: var(--axi-border-control) solid var(--axi-rule);
1213
+ }
1214
+
1215
+ /* ---------- accordion ---------- */
1216
+ /* <details>/<summary>, so it opens and closes with no script and is
1217
+ announced as a disclosure. The native marker is removed and replaced with
1218
+ the family diamond, which rotates a quarter turn on [open] - a transform,
1219
+ so it is inside rule 4's rationing without needing rule 11. */
1220
+ .axi-accordion {
1221
+ background: var(--axi-surface);
1222
+ backdrop-filter: var(--axi-surface-filter);
1223
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
1224
+ border-radius: var(--axi-radius);
1225
+ box-shadow: var(--axi-shadow-panel);
1226
+ }
1227
+ /* Stacked accordions need the gap the block occupies, or each one's block
1228
+ lands on the next one's outline. */
1229
+ .axi-accordion + .axi-accordion { margin-top: calc(var(--axi-offset-panel) + 8px); }
1230
+ .axi-accordion__head {
1231
+ display: flex; align-items: center; gap: 12px;
1232
+ padding: 14px 18px;
1233
+ cursor: pointer;
1234
+ list-style: none;
1235
+ font: var(--axi-t-label);
1236
+ letter-spacing: var(--axi-ls-label);
1237
+ color: var(--axi-text);
1238
+ }
1239
+ .axi-accordion__head::-webkit-details-marker { display: none; }
1240
+ .axi-accordion__head::before {
1241
+ content: "";
1242
+ width: 8px; height: 8px; flex: none;
1243
+ background: var(--axi-accent);
1244
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1245
+ transform: rotate(45deg);
1246
+ transition: transform .12s;
1247
+ }
1248
+ /* A 135deg turn, not the quarter turn "45deg to 180deg" might suggest by the
1249
+ numbers alone: a literal quarter turn on a square lands back on the same
1250
+ silhouette and would erase the open/closed cue. */
1251
+ .axi-accordion[open] .axi-accordion__head::before { transform: rotate(180deg); }
1252
+ .axi-accordion__body {
1253
+ padding: 0 18px 16px 38px;
1254
+ font: var(--axi-t-small);
1255
+ color: var(--axi-text-dim);
1256
+ }
1257
+
1258
+ /* ---------- toasts ---------- */
1259
+ /* Layer 60, between the drawer and the tooltip.
1260
+ The region is a layout box and nothing else, sized only by its content -
1261
+ it sets no width or height of its own, so an empty one is zero-area and
1262
+ pointer-events: none is not there to stop it covering the page. It is
1263
+ there because the flex gaps between stacked toasts would otherwise eat
1264
+ clicks aimed at the corner beneath them. The toasts themselves take their
1265
+ events back.
1266
+ This language ships no script, so appearing, stacking and leaving on a
1267
+ timer are the consumer's: insert a .axi-toast into the region and remove it
1268
+ when it is done. That is the same contract .axi-menu already publishes for
1269
+ its open state, not a new kind of promise. */
1270
+ .axi-toasts {
1271
+ position: fixed;
1272
+ right: var(--axi-gutter);
1273
+ bottom: var(--axi-gutter);
1274
+ z-index: 60;
1275
+ display: flex;
1276
+ flex-direction: column;
1277
+ align-items: flex-end;
1278
+ gap: calc(var(--axi-offset-control) + 9px);
1279
+ pointer-events: none;
1280
+ }
1281
+ /* Control weight and control block, not panel: a stack of four panels over
1282
+ the page is a wall. A toast is a chip-sized raised thing. */
1283
+ .axi-toast {
1284
+ pointer-events: auto;
1285
+ display: flex; align-items: center; gap: 12px;
1286
+ padding: 12px 15px;
1287
+ background: var(--axi-surface-raised);
1288
+ backdrop-filter: var(--axi-surface-filter);
1289
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1290
+ border-radius: var(--axi-radius-sm);
1291
+ box-shadow: var(--axi-shadow-control);
1292
+ font: var(--axi-t-small);
1293
+ color: var(--axi-text);
1294
+ }
1295
+ .axi-toast__dot {
1296
+ width: 10px; height: 10px; flex: none;
1297
+ background: var(--axi-accent);
1298
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1299
+ transform: rotate(45deg);
1300
+ }
1301
+ .axi-toast--ok .axi-toast__dot { background: var(--axi-ok); }
1302
+ .axi-toast--warn .axi-toast__dot { background: var(--axi-warn); }
1303
+ .axi-toast--danger .axi-toast__dot { background: var(--axi-danger); }
1304
+
845
1305
  /* --- data.css --- */
846
1306
  /* axi design language - data.
847
1307
  Numbers, and the shapes numbers are drawn as. Everything here is panel
@@ -1123,6 +1583,75 @@ a { color: inherit; }
1123
1583
  color: var(--axi-text-dim);
1124
1584
  }
1125
1585
 
1586
+ /* --- feedback.css --- */
1587
+ /* axi design language - feedback.
1588
+ Things that report on work, and things that report on how work went. Rule
1589
+ 11 governs the first kind and is the one rule in this language described as
1590
+ non-negotiable: an indicator of work may animate only `transform` and
1591
+ `opacity`, because those are the two the compositor runs off the main
1592
+ thread. A spinner animated by layout freezes with the work it reports on,
1593
+ and a frozen spinner tells the reader the app crashed at the exact moment
1594
+ it was working hardest. tests/motion.test.mjs enforces it. */
1595
+
1596
+ /* ---------- spinner ---------- */
1597
+ /* The family motif, turning. A square rotated 45deg is the diamond, so the
1598
+ loop runs 45deg -> 405deg: one full turn that begins and ends on the
1599
+ motif rather than on a square.
1600
+ That start and end also make it correct under reduced motion for free.
1601
+ base.css only shortens the animation to one .01ms iteration; it never sets
1602
+ animation-fill-mode, so once that instant run finishes the element reverts
1603
+ to its specified transform: rotate(45deg) - the motif at rest, and visually
1604
+ identical to where the loop begins. */
1605
+ @keyframes axi-spin { from { transform: rotate(45deg); } to { transform: rotate(405deg); } }
1606
+ .axi-spinner {
1607
+ display: inline-block;
1608
+ flex: none;
1609
+ width: var(--axi-spinner-size, 20px);
1610
+ height: var(--axi-spinner-size, 20px);
1611
+ border: var(--axi-border-control) solid var(--axi-accent);
1612
+ border-radius: var(--axi-radius-sm);
1613
+ transform: rotate(45deg);
1614
+ animation: axi-spin 1.1s linear infinite;
1615
+ }
1616
+
1617
+ /* ---------- indeterminate meter ---------- */
1618
+ /* The .axi-meter that does not know how far along it is. The fill keeps a
1619
+ fixed width and travels; `width` is never animated, which is the whole of
1620
+ rule 11. --axi-meter-v is ignored in this mode - there is no value to
1621
+ express, which is what indeterminate means. */
1622
+ @keyframes axi-meter-busy {
1623
+ from { transform: translateX(-100%); }
1624
+ to { transform: translateX(400%); }
1625
+ }
1626
+ .axi-meter--busy .axi-meter__fill {
1627
+ width: 25%;
1628
+ animation: axi-meter-busy 1.3s ease-in-out infinite;
1629
+ }
1630
+ /* base.css only ever sets animation-duration and animation-iteration-count
1631
+ under reduced motion; it never touches animation-fill-mode. The shorthand
1632
+ above resets animation-fill-mode to its default, none, so once the .01ms
1633
+ run finishes the element reverts to its specified style rather than
1634
+ holding the keyframe it ended on - here that is transform: none and the
1635
+ fill's own width: 25%, a left-anchored quarter-width bar. That is already
1636
+ the right thing to show: visible, and unmistakably not finished. This rule
1637
+ states transform: none explicitly rather than relying on the revert, so
1638
+ the "at rest" case is not accidental. */
1639
+ @media (prefers-reduced-motion: reduce) {
1640
+ .axi-meter--busy .axi-meter__fill { transform: none; }
1641
+ }
1642
+
1643
+ /* ---------- notice status ---------- */
1644
+ /* Rule 5: the icon is the part of a notice that asserts something, so the
1645
+ status lives there. The body text stays in the reading ink at every status
1646
+ - a whole paragraph in the danger ink is the tinted-everything failure rule
1647
+ 2 exists to prevent, and it is unreadable besides. */
1648
+ .axi-notice--ok .axi-notice__icon { background: var(--axi-ok); }
1649
+ .axi-notice--warn .axi-notice__icon { background: var(--axi-warn); }
1650
+ .axi-notice--danger .axi-notice__icon { background: var(--axi-danger); }
1651
+ .axi-notice--ok b { color: var(--axi-ok); }
1652
+ .axi-notice--warn b { color: var(--axi-warn); }
1653
+ .axi-notice--danger b { color: var(--axi-danger); }
1654
+
1126
1655
  /* --- prose.css --- */
1127
1656
  /* axi design language - prose.
1128
1657
  A single class wrapping rendered markdown, so a content pipeline can emit
@@ -1183,7 +1712,7 @@ a { color: inherit; }
1183
1712
  background: var(--axi-ground);
1184
1713
  border: var(--axi-border-control) solid var(--axi-ink-line);
1185
1714
  border-radius: var(--axi-radius-sm);
1186
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1715
+ box-shadow: var(--axi-shadow-control);
1187
1716
  }
1188
1717
  .axi-prose pre code { background: none; border: 0; padding: 0; font-size: 12.5px; line-height: 1.6; }
1189
1718