@axiapps/axi-design 1.10.0 → 1.12.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
@@ -19,11 +19,29 @@
19
19
  --axi-surface: #222731; /* a panel raised off the page */
20
20
  --axi-surface-raised: #2b313d; /* a chip or popover raised off a panel */
21
21
  --axi-ink-line: #0c0e12; /* the outline every raised element is drawn with */
22
+ /* The tone a shape is made of when it is made of the line ink rather than
23
+ outlined in it - the tooltip, the titlebar strip, the switch's slug. Holds
24
+ --axi-ink-line today, which is why it reads as the same decision and is
25
+ not: one is the colour a shape's EDGE is drawn in, the other the colour a
26
+ shape is FILLED with. They only have to diverge once - a theme that
27
+ outlines in a light colour so its page can go near-black - for the
28
+ conflated spelling to turn the tooltip into a pale box. Same split as
29
+ --axi-ink-on-fill below, on the other side of the same token. */
30
+ --axi-ground-deep: var(--axi-ink-line);
22
31
  --axi-rule: #3a4250; /* internal rules, inside an outlined panel */
23
32
  --axi-text: #f4f6f9;
24
33
  --axi-text-dim: #a7b0be;
25
34
  --axi-text-faint: #7c8695;
26
35
  --axi-scrim: rgba(6, 7, 9, .72); /* behind a drawer */
36
+ /* What a surface does to whatever is behind it. `none` here, and `none` is
37
+ genuinely inert - it paints nothing and, unlike a transform, establishes
38
+ no containing block, so the fixed-descendant trap rule 4 documents stays
39
+ closed. It exists so a theme has somewhere to put the answer: a glass
40
+ theme makes the surfaces translucent and restates this token, and the
41
+ blur arrives on every surface at once without one component learning the
42
+ word. This is the toll the Themes section describes - a theme cannot
43
+ invent a capability, so the capability is declared here first, inert. */
44
+ --axi-surface-filter: none;
27
45
 
28
46
  /* --- accent & status --- */
29
47
  --axi-accent: #ffc53d;
@@ -31,6 +49,13 @@
31
49
  enough to pass contrast on this ground; an app choosing a dark accent
32
50
  overrides this to --axi-text rather than editing components. */
33
51
  --axi-accent-ink: var(--axi-ink-line);
52
+ /* Text drawn on top of a STATUS fill (an ok/warn/danger chip, a close
53
+ button gone red). Identical to --axi-ink-line today, which is precisely
54
+ why it has to exist: components were spelling this "the outline colour"
55
+ and it happened to read correctly. A theme that makes the outline light
56
+ would otherwise put light text on a bright fill and lose the word. Same
57
+ idea as --axi-accent-ink above, for the fills that are not the accent. */
58
+ --axi-ink-on-fill: var(--axi-ink-line);
34
59
  --axi-meta: #4ec3ff; /* the one cool ink: meta only, never a status */
35
60
  --axi-ok: #2fd38a;
36
61
  --axi-warn: #ff7a2f;
@@ -49,6 +74,15 @@
49
74
  block instead of admitting bare literals. */
50
75
  --axi-offset-panel-hover: 10px;
51
76
  --axi-offset-control-hover: 6px;
77
+ /* The block itself, composed once. A component asks for "the panel block",
78
+ not for an offset it then has to assemble into a shadow correctly four
79
+ times per file - and a theme gets one place to restate what raising a
80
+ thing looks like. The shape is still rule 3's: two equal offsets, zero
81
+ blur, the outline ink. */
82
+ --axi-shadow-panel: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
83
+ --axi-shadow-control: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
84
+ --axi-shadow-panel-hover: var(--axi-offset-panel-hover) var(--axi-offset-panel-hover) 0 var(--axi-ink-line);
85
+ --axi-shadow-control-hover: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
52
86
  /* The form is sharp. A hard outline, a hard block and a rounded corner are
53
87
  three decisions, and the rounded one softens the other two: the corner is
54
88
  the first place a flat outlined shape can start looking like a button in
@@ -178,9 +212,19 @@ a { color: inherit; }
178
212
  us to ship a component for it. */
179
213
  .axi-panel {
180
214
  background: var(--axi-surface);
215
+ /* What the surface does to whatever is behind it. `none` in the main theme,
216
+ which is inert: unlike a transform, `backdrop-filter: none` establishes no
217
+ containing block, so rule 4's fixed-descendant trap is not reopened here.
218
+ A theme that makes the surfaces translucent restates this token and the
219
+ blur arrives everywhere at once. Carried only by the surfaces something
220
+ can actually be behind - a panel, a popover, an overlay, the scrim. A
221
+ chip, a switch, a page number and a card glyph sit directly on their
222
+ parent's fill with nothing but that fill behind them, so the filter would
223
+ cost a compositor layer each to blur a flat colour. */
224
+ backdrop-filter: var(--axi-surface-filter);
181
225
  border: var(--axi-border-panel) solid var(--axi-ink-line);
182
226
  border-radius: var(--axi-radius);
183
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
227
+ box-shadow: var(--axi-shadow-panel);
184
228
  /* A panel owns its padding so every consumer doesn't have to invent one;
185
229
  override per instance with --axi-panel-pad where a tighter or looser
186
230
  fit is genuinely called for. */
@@ -207,13 +251,13 @@ a { color: inherit; }
207
251
  vision and costs no colour. */
208
252
  .axi-btn:hover {
209
253
  color: var(--axi-text);
210
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
254
+ box-shadow: var(--axi-shadow-control);
211
255
  transform: translate(-2px, -2px);
212
256
  }
213
257
  .axi-btn--primary {
214
258
  background: var(--axi-accent);
215
259
  color: var(--axi-accent-ink);
216
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
260
+ box-shadow: var(--axi-shadow-control);
217
261
  }
218
262
  /* The one button that rests with a block already under it, so the generic
219
263
  hover's translate alone would move element and block together and leave the
@@ -221,7 +265,7 @@ a { color: inherit; }
221
265
  rule 4 warns about. It lifts the way a panel does: the block deepens too. */
222
266
  .axi-btn--primary:hover {
223
267
  color: var(--axi-accent-ink);
224
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
268
+ box-shadow: var(--axi-shadow-control-hover);
225
269
  }
226
270
  .axi-btn--ghost { background: transparent; }
227
271
  .axi-btn--dashed { background: transparent; border-style: dashed; }
@@ -247,13 +291,13 @@ a { color: inherit; }
247
291
  }
248
292
  .axi-pill:hover {
249
293
  color: var(--axi-text);
250
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
294
+ box-shadow: var(--axi-shadow-control);
251
295
  transform: translate(-2px, -2px);
252
296
  }
253
297
  .axi-pill[aria-pressed="true"] {
254
298
  background: var(--axi-pill-fill, var(--axi-accent));
255
299
  color: var(--axi-accent-ink);
256
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
300
+ box-shadow: var(--axi-shadow-control);
257
301
  }
258
302
  /* Spelled out rather than left to source order: `.axi-pill:hover` and
259
303
  `.axi-pill[aria-pressed="true"]` have identical specificity, so without this
@@ -263,7 +307,7 @@ a { color: inherit; }
263
307
  .axi-pill[aria-pressed="true"]:hover {
264
308
  background: var(--axi-pill-fill, var(--axi-accent));
265
309
  color: var(--axi-accent-ink);
266
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
310
+ box-shadow: var(--axi-shadow-control-hover);
267
311
  transform: translate(-2px, -2px);
268
312
  }
269
313
 
@@ -307,7 +351,7 @@ a { color: inherit; }
307
351
  top: 2px; bottom: 2px; left: 2px;
308
352
  width: var(--axi-switch-knob, 16px);
309
353
  border-radius: var(--axi-radius-sm);
310
- background: var(--axi-ink-line);
354
+ background: var(--axi-ground-deep);
311
355
  transition: transform .1s;
312
356
  }
313
357
  /* The travel is the track's inner width less the slug and its two insets.
@@ -337,9 +381,9 @@ a { color: inherit; }
337
381
  letter-spacing: var(--axi-ls-micro);
338
382
  text-transform: uppercase;
339
383
  }
340
- .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-line); }
341
- .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-line); }
342
- .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-line); }
384
+ .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-on-fill); }
385
+ .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-on-fill); }
386
+ .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
343
387
  .axi-chip--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
344
388
  .axi-chip--meta {
345
389
  background: transparent;
@@ -435,7 +479,7 @@ a { color: inherit; }
435
479
  .axi-picker__btn:hover,
436
480
  .axi-picker__btn[aria-expanded='true'] {
437
481
  color: var(--axi-text);
438
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
482
+ box-shadow: var(--axi-shadow-control);
439
483
  transform: translate(-2px, -2px);
440
484
  }
441
485
 
@@ -456,7 +500,8 @@ a { color: inherit; }
456
500
  border: var(--axi-border-panel) solid var(--axi-ink-line);
457
501
  border-radius: var(--axi-radius);
458
502
  background: var(--axi-surface-raised);
459
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
503
+ backdrop-filter: var(--axi-surface-filter);
504
+ box-shadow: var(--axi-shadow-panel);
460
505
  }
461
506
  .axi-select option {
462
507
  display: flex; align-items: center; gap: 9px;
@@ -527,12 +572,13 @@ a { color: inherit; }
527
572
  max-height: 340px; overflow-y: auto;
528
573
  padding: 6px;
529
574
  background: var(--axi-surface-raised);
575
+ backdrop-filter: var(--axi-surface-filter);
530
576
  border: var(--axi-border-panel) solid var(--axi-ink-line);
531
577
  border-radius: var(--axi-radius);
532
578
  /* The block falls outside the popover's own box. An ancestor that clips -
533
579
  a scrolling pane, a panel with overflow: hidden - eats it, and the only
534
580
  shadow on the screen is the one that goes missing. */
535
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
581
+ box-shadow: var(--axi-shadow-panel);
536
582
  }
537
583
  .axi-picker__pop[hidden] { display: none; }
538
584
  /* A popover cannot always live beside its trigger. Inside a pane that scrolls
@@ -574,6 +620,125 @@ a { color: inherit; }
574
620
  .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
575
621
  .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }
576
622
 
623
+ /* ---------- avatar ---------- */
624
+ /* A square, because a circle is the one shape this language does not have:
625
+ --axi-radius is 0, and a round avatar would be the only rounded thing on
626
+ the page. It follows the radius scale like everything else, so a consumer
627
+ who wants round avatars sets --axi-radius-sm and gets rounded controls
628
+ everywhere - which is the honest version of the request.
629
+ Flat: outlined, no block. An avatar is content inside a panel, the same as
630
+ .axi-stat and .axi-table__rank, not a thing raised off it. */
631
+ .axi-avatar {
632
+ width: var(--axi-avatar-size, 40px);
633
+ height: var(--axi-avatar-size, 40px);
634
+ flex: none;
635
+ display: grid;
636
+ place-items: center;
637
+ overflow: hidden;
638
+ background: var(--axi-ground);
639
+ color: var(--axi-text-dim);
640
+ border: var(--axi-border-control) solid var(--axi-ink-line);
641
+ border-radius: var(--axi-radius-sm);
642
+ font: var(--axi-t-label);
643
+ letter-spacing: var(--axi-ls-label);
644
+ text-transform: uppercase;
645
+ }
646
+ .axi-avatar--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
647
+ /* object-fit rather than a background-image, so the <img>'s alt text survives
648
+ and a broken source is visible rather than silently blank. */
649
+ .axi-avatar__img { width: 100%; height: 100%; object-fit: cover; display: block; }
650
+
651
+ /* --- forms.css --- */
652
+ /* axi design language - forms.
653
+ Real inputs, styled. Every control here is an actual <input> or <textarea>
654
+ with `appearance: none`, never a <div> wearing a class: a checkbox that is
655
+ not an <input> does not submit with its form, does not toggle from the
656
+ keyboard, and is not announced as a checkbox. The language styles controls;
657
+ it does not reimplement them.
658
+
659
+ Rule 3's flat case governs the geometry: a control inside a panel is
660
+ outlined and carries no block, exactly as .axi-input and .axi-switch
661
+ already are. Rule 5 governs the state: the fill is what says "on".
662
+
663
+ The focus ring is deliberately absent from this file. base.css draws one
664
+ page-wide on :focus-visible, and appearance: none does not remove it - it
665
+ removes the browser's own. Suppressing the outline anywhere here would make
666
+ every control in it unusable by keyboard. */
667
+
668
+ /* ---------- checkbox and radio ---------- */
669
+ /* One box, two marks. --axi-radius is 0, so both are square and a round radio
670
+ would be the only rounded shape in the language - the first place a flat
671
+ outlined form starts looking like some other framework's. So they are told
672
+ apart by their mark instead: the checkbox gets a check, the radio gets the
673
+ family diamond (rule 7).
674
+
675
+ Both read the same two knobs on purpose. A form holding checkboxes and
676
+ radios together should not need two properties set to the same value to
677
+ keep its controls the same size. */
678
+ .axi-check,
679
+ .axi-radio {
680
+ appearance: none;
681
+ -webkit-appearance: none;
682
+ margin: 0;
683
+ flex: none;
684
+ width: var(--axi-check-size, 22px);
685
+ height: var(--axi-check-size, 22px);
686
+ display: inline-grid;
687
+ place-items: center;
688
+ background: var(--axi-ground);
689
+ border: var(--axi-border-control) solid var(--axi-ink-line);
690
+ border-radius: var(--axi-radius-sm);
691
+ cursor: pointer;
692
+ }
693
+ /* The mark is always in the DOM and revealed with opacity rather than being
694
+ created on :checked, so the box never changes size as it toggles. */
695
+ .axi-check::after {
696
+ content: "";
697
+ width: 55%;
698
+ height: 30%;
699
+ opacity: 0;
700
+ border-left: var(--axi-border-control) solid var(--axi-accent-ink);
701
+ border-bottom: var(--axi-border-control) solid var(--axi-accent-ink);
702
+ transform: translateY(-12%) rotate(-45deg);
703
+ }
704
+ .axi-check:checked { background: var(--axi-check-fill, var(--axi-accent)); }
705
+ .axi-check:checked::after { opacity: 1; }
706
+ /* The radio's mark is the diamond itself, so it is drawn in the fill rather
707
+ than on it - there is no filled box underneath it to sit on. It takes the
708
+ hairline outline rule 7's diamond wears at this scale (see the prose
709
+ bullet, src/prose.css, the other place a diamond this small is drawn) -
710
+ the mark is revealed with opacity, so the hairline is hidden along with
711
+ the fill in the unchecked state and never leaks out on its own. */
712
+ .axi-radio::after {
713
+ content: "";
714
+ width: 46%;
715
+ height: 46%;
716
+ opacity: 0;
717
+ background: var(--axi-check-fill, var(--axi-accent));
718
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
719
+ transform: rotate(45deg);
720
+ }
721
+ .axi-radio:checked::after { opacity: 1; }
722
+ .axi-check:disabled,
723
+ .axi-radio:disabled { cursor: not-allowed; opacity: .5; }
724
+
725
+ /* ---------- textarea ---------- */
726
+ /* Not a component: the same .axi-input a single-line field uses, with the two
727
+ things a <textarea> needs that an <input> does not. The font is restated
728
+ because a textarea does not inherit the page font in any browser, and a
729
+ form whose notes field is monospace while its name field is not is a bug
730
+ nobody files and everybody sees.
731
+
732
+ Resizing is vertical only. A textarea dragged wider than its field breaks
733
+ the column its form is laid out in, and the reader who did it has no way
734
+ to discover that the layout is not at fault. */
735
+ textarea.axi-input {
736
+ min-height: var(--axi-textarea-h, 90px);
737
+ resize: vertical;
738
+ font-family: var(--axi-sans);
739
+ line-height: 1.5;
740
+ }
741
+
577
742
  /* --- layout.css --- */
578
743
  /* axi design language - layout.
579
744
  Three measures, one grid, two spacing helpers. Deliberately small: a
@@ -689,16 +854,17 @@ a { color: inherit; }
689
854
  background: var(--axi-accent);
690
855
  color: var(--axi-accent-ink);
691
856
  border-color: var(--axi-ink-line);
692
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
857
+ box-shadow: var(--axi-shadow-control);
693
858
  }
694
859
 
695
860
  /* ---------- notice ---------- */
696
861
  .axi-notice {
697
862
  display: flex; gap: 14px; padding: 15px 18px;
698
863
  background: var(--axi-surface);
864
+ backdrop-filter: var(--axi-surface-filter);
699
865
  border: var(--axi-border-panel) solid var(--axi-ink-line);
700
866
  border-radius: var(--axi-radius);
701
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
867
+ box-shadow: var(--axi-shadow-panel);
702
868
  }
703
869
  .axi-notice__icon {
704
870
  flex: none; width: 26px; height: 26px;
@@ -716,9 +882,10 @@ a { color: inherit; }
716
882
  display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
717
883
  padding: 14px;
718
884
  background: var(--axi-surface);
885
+ backdrop-filter: var(--axi-surface-filter);
719
886
  border: var(--axi-border-panel) solid var(--axi-ink-line);
720
887
  border-radius: var(--axi-radius);
721
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
888
+ box-shadow: var(--axi-shadow-panel);
722
889
  }
723
890
 
724
891
  /* ---------- menu popover ---------- */
@@ -733,9 +900,10 @@ a { color: inherit; }
733
900
  width: var(--axi-menu-width, 310px);
734
901
  max-height: 340px; overflow-y: auto; padding: 8px;
735
902
  background: var(--axi-surface-raised);
903
+ backdrop-filter: var(--axi-surface-filter);
736
904
  border: var(--axi-border-panel) solid var(--axi-ink-line);
737
905
  border-radius: var(--axi-radius);
738
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
906
+ box-shadow: var(--axi-shadow-panel);
739
907
  }
740
908
  .axi-menu__pop[hidden] { display: none; }
741
909
  .axi-menu__pop label {
@@ -744,7 +912,7 @@ a { color: inherit; }
744
912
  font-size: 13px; font-weight: 600; color: var(--axi-text-dim); cursor: pointer;
745
913
  }
746
914
  .axi-menu__pop label:hover { background: var(--axi-ground); color: var(--axi-text); }
747
- .axi-menu__pop input { margin: 3px 0 0; accent-color: var(--axi-accent); }
915
+ .axi-menu__pop input { margin: 3px 0 0; }
748
916
 
749
917
  /* ---------- tooltip ---------- */
750
918
  /* One line of ink on the darkest surface in the language, so it reads over
@@ -757,9 +925,13 @@ a { color: inherit; }
757
925
  exists nowhere else, because a keyboard or touch user may never see it. */
758
926
  .axi-tooltip {
759
927
  position: fixed;
760
- z-index: 99999;
928
+ /* Layer 70: above the drawer and its scrim, above toasts, below a
929
+ showModal()-ed dialog's top layer - which it was below anyway, since the
930
+ top layer is above every z-index. 99999 was not a layer, it was an
931
+ argument with one. */
932
+ z-index: 70;
761
933
  padding: 5px 8px;
762
- background: var(--axi-ink-line);
934
+ background: var(--axi-ground-deep);
763
935
  color: var(--axi-text);
764
936
  border: var(--axi-border-hairline) solid var(--axi-rule);
765
937
  border-radius: var(--axi-radius-sm);
@@ -775,15 +947,16 @@ a { color: inherit; }
775
947
  display: flex; flex-direction: column; gap: 11px;
776
948
  padding: 22px 16px 14px;
777
949
  background: var(--axi-surface);
950
+ backdrop-filter: var(--axi-surface-filter);
778
951
  border: var(--axi-border-panel) solid var(--axi-ink-line);
779
952
  border-radius: var(--axi-radius);
780
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
953
+ box-shadow: var(--axi-shadow-panel);
781
954
  font: inherit; color: inherit; text-decoration: none; cursor: pointer;
782
955
  transition: transform .1s, box-shadow .1s;
783
956
  }
784
957
  .axi-card:hover {
785
958
  transform: translate(-3px, -3px);
786
- box-shadow: var(--axi-offset-panel-hover) var(--axi-offset-panel-hover) 0 var(--axi-ink-line);
959
+ box-shadow: var(--axi-shadow-panel-hover);
787
960
  }
788
961
  /* The top strip is drawn from the card itself so a renderer does not have to
789
962
  emit an extra element for it. It is opt-in: a card only gets a strip when
@@ -831,12 +1004,13 @@ a { color: inherit; }
831
1004
  .axi-card:hover .axi-card__go { color: var(--axi-text); }
832
1005
 
833
1006
  /* ---------- drawer ---------- */
834
- .axi-scrim { position: fixed; inset: 0; z-index: 50; background: var(--axi-scrim); border: 0; }
1007
+ .axi-scrim { position: fixed; inset: 0; z-index: 50; background: var(--axi-scrim); backdrop-filter: var(--axi-surface-filter); border: 0; }
835
1008
  .axi-drawer {
836
1009
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 51;
837
1010
  width: min(var(--axi-drawer-width, 560px), 100vw);
838
1011
  display: flex; flex-direction: column;
839
1012
  background: var(--axi-surface);
1013
+ backdrop-filter: var(--axi-surface-filter);
840
1014
  border-left: var(--axi-border-panel) solid var(--axi-ink-line);
841
1015
  }
842
1016
  .axi-drawer[hidden], .axi-scrim[hidden] { display: none; }
@@ -862,7 +1036,7 @@ a { color: inherit; }
862
1036
  }
863
1037
  .axi-drawer__close:hover {
864
1038
  background: var(--axi-accent); color: var(--axi-accent-ink);
865
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1039
+ box-shadow: var(--axi-shadow-control);
866
1040
  transform: translate(-2px, -2px);
867
1041
  }
868
1042
  .axi-drawer__body { overflow-y: auto; padding: 18px 24px 30px; }
@@ -906,7 +1080,7 @@ a { color: inherit; }
906
1080
  flex: none;
907
1081
  display: flex; align-items: center; gap: 12px;
908
1082
  height: 38px; padding-left: 14px;
909
- background: var(--axi-ink-line);
1083
+ background: var(--axi-ground-deep);
910
1084
  border-bottom: var(--axi-border-control) solid var(--axi-ink-line);
911
1085
  font: var(--axi-t-micro);
912
1086
  letter-spacing: var(--axi-ls-micro);
@@ -930,10 +1104,70 @@ a { color: inherit; }
930
1104
  .axi-titlebar__btns button:hover { background: var(--axi-surface); color: var(--axi-text); }
931
1105
  /* Close is the one destructive control in the chrome, and the one place a
932
1106
  status ink belongs in a titlebar. */
933
- .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-line); }
1107
+ .axi-titlebar__btns button:last-child:hover { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
934
1108
  /* The scrolling region under the chrome. */
935
1109
  .axi-window__body { flex: 1; overflow-y: auto; }
936
1110
 
1111
+ /* ---------- breadcrumbs ---------- */
1112
+ /* A path, not a control strip. The separator is the family diamond (rule 7)
1113
+ drawn in the rule ink, so it reads as punctuation rather than as a third
1114
+ link the reader could click.
1115
+ The current crumb is NOT filled. Rule 5 reserves a fill for a state
1116
+ something is in - pressed, on, current-as-in-selected - and the last crumb
1117
+ is a position, not a control that is on. It takes the full text ink and the
1118
+ label weight instead. */
1119
+ .axi-crumbs {
1120
+ display: flex;
1121
+ align-items: center;
1122
+ gap: 10px;
1123
+ flex-wrap: wrap;
1124
+ font: var(--axi-t-small);
1125
+ }
1126
+ .axi-crumbs a { color: var(--axi-text-dim); text-decoration: none; }
1127
+ .axi-crumbs a:hover { color: var(--axi-accent); }
1128
+ /* 7px, matching the prose bullet (src/prose.css) exactly, with the same
1129
+ --axi-border-hairline outline: rule 7 calls the motif "a 45deg-rotated
1130
+ outlined square", and a bare fill with no border does not match it. The
1131
+ hairline, not one of the two form steps, is the established weight for a
1132
+ diamond this small - at 6px a 2px border on each side would leave only a
1133
+ 2px fill, too tight to read as a diamond at all. */
1134
+ .axi-crumbs__sep {
1135
+ width: 7px; height: 7px; flex: none;
1136
+ background: var(--axi-rule);
1137
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1138
+ transform: rotate(45deg);
1139
+ }
1140
+ .axi-crumbs [aria-current="page"] { color: var(--axi-text); font-weight: 800; }
1141
+
1142
+ /* ---------- pagination ---------- */
1143
+ /* The same idea .axi-tabs already expresses, applied to a row of pages: the
1144
+ current one is filled and blocked, because "this one is on" is what a fill
1145
+ means here. Written as its own component rather than as a tabs modifier
1146
+ because a pager sits under content and a tab strip sits in a titlebar, and
1147
+ the two disagree about everything except that treatment. */
1148
+ .axi-pages { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; }
1149
+ .axi-pages__n {
1150
+ min-width: 36px;
1151
+ padding: 8px 10px;
1152
+ text-align: center;
1153
+ font: var(--axi-t-label);
1154
+ letter-spacing: var(--axi-ls-label);
1155
+ color: var(--axi-text-dim);
1156
+ text-decoration: none;
1157
+ background: var(--axi-surface-raised);
1158
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1159
+ border-radius: var(--axi-radius-sm);
1160
+ }
1161
+ .axi-pages__n:hover { color: var(--axi-text); }
1162
+ .axi-pages__n[aria-current="page"] {
1163
+ background: var(--axi-accent);
1164
+ color: var(--axi-accent-ink);
1165
+ box-shadow: var(--axi-shadow-control);
1166
+ }
1167
+ /* The elided run between two page numbers. Not a control and never focusable:
1168
+ it is the only thing in the row a reader cannot go to. */
1169
+ .axi-pages__gap { color: var(--axi-text-faint); padding: 0 2px; }
1170
+
937
1171
  @media (max-width: 640px) {
938
1172
  .axi-mast__in { min-height: 0; padding: 10px 16px; gap: 12px; }
939
1173
  .axi-tabs { margin-left: 0; width: 100%; overflow-x: auto; }
@@ -943,13 +1177,140 @@ a { color: inherit; }
943
1177
  to the panel hover block, so a card on a hover-emulating touch device
944
1178
  jumped further than it does on the desktop it was tuned for. */
945
1179
  .axi-notice, .axi-toolbar, .axi-card {
946
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1180
+ box-shadow: var(--axi-shadow-control);
947
1181
  }
948
1182
  .axi-card:hover {
949
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
1183
+ box-shadow: var(--axi-shadow-control-hover);
950
1184
  }
951
1185
  }
952
1186
 
1187
+ /* ---------- modal ---------- */
1188
+ /* A real <dialog>. Focus trapping, Esc-to-close, inertness for everything
1189
+ behind it and promotion to the top layer all come from the browser, and
1190
+ none of them can be had from CSS - so the alternative was a <div> plus a
1191
+ focus trap this language has no script to write.
1192
+ That choice has one visible cost, and it is named here rather than left to
1193
+ be discovered: a <dialog> brings its own ::backdrop, so the modal styles
1194
+ that instead of reusing .axi-scrim. The two look identical and are not the
1195
+ same element. Reusing .axi-scrim would have meant giving up <dialog>.
1196
+ Panel weight and panel block: it is the most raised thing on the page. */
1197
+ .axi-modal {
1198
+ padding: 0;
1199
+ max-width: min(var(--axi-modal-width, 560px), calc(100vw - 32px));
1200
+ background: var(--axi-surface);
1201
+ backdrop-filter: var(--axi-surface-filter);
1202
+ color: var(--axi-text);
1203
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
1204
+ border-radius: var(--axi-radius);
1205
+ box-shadow: var(--axi-shadow-panel);
1206
+ }
1207
+ .axi-modal::backdrop { background: var(--axi-scrim); backdrop-filter: var(--axi-surface-filter); }
1208
+ /* The head and foot are divided from the body by rules, not by outlines: they
1209
+ are parts of one raised thing, and rule 8's reading applies - a line inside
1210
+ a raised surface separates its parts, it does not raise them. */
1211
+ .axi-modal__head {
1212
+ display: flex; align-items: center; gap: 12px;
1213
+ padding: 15px 18px;
1214
+ border-bottom: var(--axi-border-control) solid var(--axi-rule);
1215
+ }
1216
+ .axi-modal__head h2 { font: var(--axi-t-h3); letter-spacing: var(--axi-ls-h3); margin: 0; }
1217
+ .axi-modal__body { padding: 18px; font: var(--axi-t-small); color: var(--axi-text-dim); }
1218
+ .axi-modal__foot {
1219
+ display: flex; justify-content: flex-end; gap: 9px;
1220
+ padding: 15px 18px;
1221
+ border-top: var(--axi-border-control) solid var(--axi-rule);
1222
+ }
1223
+
1224
+ /* ---------- accordion ---------- */
1225
+ /* <details>/<summary>, so it opens and closes with no script and is
1226
+ announced as a disclosure. The native marker is removed and replaced with
1227
+ the family diamond, which rotates a quarter turn on [open] - a transform,
1228
+ so it is inside rule 4's rationing without needing rule 11. */
1229
+ .axi-accordion {
1230
+ background: var(--axi-surface);
1231
+ backdrop-filter: var(--axi-surface-filter);
1232
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
1233
+ border-radius: var(--axi-radius);
1234
+ box-shadow: var(--axi-shadow-panel);
1235
+ }
1236
+ /* Stacked accordions need the gap the block occupies, or each one's block
1237
+ lands on the next one's outline. */
1238
+ .axi-accordion + .axi-accordion { margin-top: calc(var(--axi-offset-panel) + 8px); }
1239
+ .axi-accordion__head {
1240
+ display: flex; align-items: center; gap: 12px;
1241
+ padding: 14px 18px;
1242
+ cursor: pointer;
1243
+ list-style: none;
1244
+ font: var(--axi-t-label);
1245
+ letter-spacing: var(--axi-ls-label);
1246
+ color: var(--axi-text);
1247
+ }
1248
+ .axi-accordion__head::-webkit-details-marker { display: none; }
1249
+ .axi-accordion__head::before {
1250
+ content: "";
1251
+ width: 8px; height: 8px; flex: none;
1252
+ background: var(--axi-accent);
1253
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1254
+ transform: rotate(45deg);
1255
+ transition: transform .12s;
1256
+ }
1257
+ /* A 135deg turn, not the quarter turn "45deg to 180deg" might suggest by the
1258
+ numbers alone: a literal quarter turn on a square lands back on the same
1259
+ silhouette and would erase the open/closed cue. */
1260
+ .axi-accordion[open] .axi-accordion__head::before { transform: rotate(180deg); }
1261
+ .axi-accordion__body {
1262
+ padding: 0 18px 16px 38px;
1263
+ font: var(--axi-t-small);
1264
+ color: var(--axi-text-dim);
1265
+ }
1266
+
1267
+ /* ---------- toasts ---------- */
1268
+ /* Layer 60, between the drawer and the tooltip.
1269
+ The region is a layout box and nothing else, sized only by its content -
1270
+ it sets no width or height of its own, so an empty one is zero-area and
1271
+ pointer-events: none is not there to stop it covering the page. It is
1272
+ there because the flex gaps between stacked toasts would otherwise eat
1273
+ clicks aimed at the corner beneath them. The toasts themselves take their
1274
+ events back.
1275
+ This language ships no script, so appearing, stacking and leaving on a
1276
+ timer are the consumer's: insert a .axi-toast into the region and remove it
1277
+ when it is done. That is the same contract .axi-menu already publishes for
1278
+ its open state, not a new kind of promise. */
1279
+ .axi-toasts {
1280
+ position: fixed;
1281
+ right: var(--axi-gutter);
1282
+ bottom: var(--axi-gutter);
1283
+ z-index: 60;
1284
+ display: flex;
1285
+ flex-direction: column;
1286
+ align-items: flex-end;
1287
+ gap: calc(var(--axi-offset-control) + 9px);
1288
+ pointer-events: none;
1289
+ }
1290
+ /* Control weight and control block, not panel: a stack of four panels over
1291
+ the page is a wall. A toast is a chip-sized raised thing. */
1292
+ .axi-toast {
1293
+ pointer-events: auto;
1294
+ display: flex; align-items: center; gap: 12px;
1295
+ padding: 12px 15px;
1296
+ background: var(--axi-surface-raised);
1297
+ backdrop-filter: var(--axi-surface-filter);
1298
+ border: var(--axi-border-control) solid var(--axi-ink-line);
1299
+ border-radius: var(--axi-radius-sm);
1300
+ box-shadow: var(--axi-shadow-control);
1301
+ font: var(--axi-t-small);
1302
+ color: var(--axi-text);
1303
+ }
1304
+ .axi-toast__dot {
1305
+ width: 10px; height: 10px; flex: none;
1306
+ background: var(--axi-accent);
1307
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
1308
+ transform: rotate(45deg);
1309
+ }
1310
+ .axi-toast--ok .axi-toast__dot { background: var(--axi-ok); }
1311
+ .axi-toast--warn .axi-toast__dot { background: var(--axi-warn); }
1312
+ .axi-toast--danger .axi-toast__dot { background: var(--axi-danger); }
1313
+
953
1314
  /* --- data.css --- */
954
1315
  /* axi design language - data.
955
1316
  Numbers, and the shapes numbers are drawn as. Everything here is panel
@@ -1231,6 +1592,75 @@ a { color: inherit; }
1231
1592
  color: var(--axi-text-dim);
1232
1593
  }
1233
1594
 
1595
+ /* --- feedback.css --- */
1596
+ /* axi design language - feedback.
1597
+ Things that report on work, and things that report on how work went. Rule
1598
+ 11 governs the first kind and is the one rule in this language described as
1599
+ non-negotiable: an indicator of work may animate only `transform` and
1600
+ `opacity`, because those are the two the compositor runs off the main
1601
+ thread. A spinner animated by layout freezes with the work it reports on,
1602
+ and a frozen spinner tells the reader the app crashed at the exact moment
1603
+ it was working hardest. tests/motion.test.mjs enforces it. */
1604
+
1605
+ /* ---------- spinner ---------- */
1606
+ /* The family motif, turning. A square rotated 45deg is the diamond, so the
1607
+ loop runs 45deg -> 405deg: one full turn that begins and ends on the
1608
+ motif rather than on a square.
1609
+ That start and end also make it correct under reduced motion for free.
1610
+ base.css only shortens the animation to one .01ms iteration; it never sets
1611
+ animation-fill-mode, so once that instant run finishes the element reverts
1612
+ to its specified transform: rotate(45deg) - the motif at rest, and visually
1613
+ identical to where the loop begins. */
1614
+ @keyframes axi-spin { from { transform: rotate(45deg); } to { transform: rotate(405deg); } }
1615
+ .axi-spinner {
1616
+ display: inline-block;
1617
+ flex: none;
1618
+ width: var(--axi-spinner-size, 20px);
1619
+ height: var(--axi-spinner-size, 20px);
1620
+ border: var(--axi-border-control) solid var(--axi-accent);
1621
+ border-radius: var(--axi-radius-sm);
1622
+ transform: rotate(45deg);
1623
+ animation: axi-spin 1.1s linear infinite;
1624
+ }
1625
+
1626
+ /* ---------- indeterminate meter ---------- */
1627
+ /* The .axi-meter that does not know how far along it is. The fill keeps a
1628
+ fixed width and travels; `width` is never animated, which is the whole of
1629
+ rule 11. --axi-meter-v is ignored in this mode - there is no value to
1630
+ express, which is what indeterminate means. */
1631
+ @keyframes axi-meter-busy {
1632
+ from { transform: translateX(-100%); }
1633
+ to { transform: translateX(400%); }
1634
+ }
1635
+ .axi-meter--busy .axi-meter__fill {
1636
+ width: 25%;
1637
+ animation: axi-meter-busy 1.3s ease-in-out infinite;
1638
+ }
1639
+ /* base.css only ever sets animation-duration and animation-iteration-count
1640
+ under reduced motion; it never touches animation-fill-mode. The shorthand
1641
+ above resets animation-fill-mode to its default, none, so once the .01ms
1642
+ run finishes the element reverts to its specified style rather than
1643
+ holding the keyframe it ended on - here that is transform: none and the
1644
+ fill's own width: 25%, a left-anchored quarter-width bar. That is already
1645
+ the right thing to show: visible, and unmistakably not finished. This rule
1646
+ states transform: none explicitly rather than relying on the revert, so
1647
+ the "at rest" case is not accidental. */
1648
+ @media (prefers-reduced-motion: reduce) {
1649
+ .axi-meter--busy .axi-meter__fill { transform: none; }
1650
+ }
1651
+
1652
+ /* ---------- notice status ---------- */
1653
+ /* Rule 5: the icon is the part of a notice that asserts something, so the
1654
+ status lives there. The body text stays in the reading ink at every status
1655
+ - a whole paragraph in the danger ink is the tinted-everything failure rule
1656
+ 2 exists to prevent, and it is unreadable besides. */
1657
+ .axi-notice--ok .axi-notice__icon { background: var(--axi-ok); }
1658
+ .axi-notice--warn .axi-notice__icon { background: var(--axi-warn); }
1659
+ .axi-notice--danger .axi-notice__icon { background: var(--axi-danger); }
1660
+ .axi-notice--ok b { color: var(--axi-ok); }
1661
+ .axi-notice--warn b { color: var(--axi-warn); }
1662
+ .axi-notice--danger b { color: var(--axi-danger); }
1663
+
1234
1664
  /* --- prose.css --- */
1235
1665
  /* axi design language - prose.
1236
1666
  A single class wrapping rendered markdown, so a content pipeline can emit
@@ -1291,7 +1721,7 @@ a { color: inherit; }
1291
1721
  background: var(--axi-ground);
1292
1722
  border: var(--axi-border-control) solid var(--axi-ink-line);
1293
1723
  border-radius: var(--axi-radius-sm);
1294
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
1724
+ box-shadow: var(--axi-shadow-control);
1295
1725
  }
1296
1726
  .axi-prose pre code { background: none; border: 0; padding: 0; font-size: 12.5px; line-height: 1.6; }
1297
1727