@axiapps/axi-design 1.10.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.
@@ -0,0 +1,11 @@
1
+ /* axi design language - generated by scripts/build.mjs.
2
+ Edit the files in src/ and run `npm run build`; do not edit this file. */
3
+
4
+ [data-axi-theme="glass"] {
5
+ --axi-ground: #141924;
6
+ --axi-surface: rgba(255, 255, 255, .09);
7
+ --axi-surface-raised: rgba(255, 255, 255, .15);
8
+ --axi-rule: rgba(255, 255, 255, .17);
9
+ --axi-scrim: rgba(6, 7, 9, .55);
10
+ --axi-surface-filter: blur(14px) saturate(140%);
11
+ }
package/docs/RULES.md CHANGED
@@ -14,7 +14,12 @@ to draw a *shape*, with no soft transition anywhere in them: the select caret
14
14
  (two `linear-gradient`s meeting to make a triangle) and `.axi-plot`'s
15
15
  gridlines (a `repeating-linear-gradient` of hard stops, which is how N evenly
16
16
  spaced rules get drawn without asking every consumer to emit N empty divs).
17
- A gradient across a surface is still forbidden, and always will be.
17
+ A gradient across a surface is still forbidden in every component file, and
18
+ always will be. The single relief is a *theme* restating the surface tokens
19
+ themselves — see [Themes](#themes) — which is what lets a glass theme exist
20
+ without one component ever learning the word "glass". A component cannot reach
21
+ for that exception, because it cannot see it: what it reads is the same surface
22
+ token it was already reading.
18
23
 
19
24
  ## 2. No colour at partial opacity over the ground
20
25
 
@@ -23,9 +28,19 @@ just brown, and five muted inks over near-black are five browns. When something
23
28
  should be quieter, reach for a neutral from the ramp — that is what the ramp is
24
29
  for.
25
30
 
31
+ A theme gets the same carve-out as rule 1 and not one inch more: it may hold the
32
+ *surface* tokens at partial opacity, because a translucent surface is that
33
+ surface's own definition rather than a colour laid over the ground. The inks are
34
+ untouched. A muted `--axi-warn`, a faded accent, a status ink at 60% — still
35
+ forbidden, in a theme exactly as in a component, because the paragraph above is
36
+ about what happens to meaning when five inks become five browns, and changing
37
+ which stylesheet does the muting does not change that.
38
+
26
39
  ## 3. Every raised element is outlined and blocked
27
40
 
28
41
  An `--axi-ink-line` border plus a hard offset shadow, never a blur.
42
+ A component spells that shadow `var(--axi-shadow-panel)` or
43
+ `var(--axi-shadow-control)`; the offsets those compose are in the table below.
29
44
 
30
45
  Two weight steps, and only two:
31
46
 
@@ -37,10 +52,21 @@ Two weight steps, and only two:
37
52
  A third step is how a system stops looking like one system.
38
53
 
39
54
  There is one weight outside the table, and it is deliberately not a step:
40
- `--axi-border-hairline` (2px), used only inside `.axi-prose` — for inline
41
- code, table rules and the list bullet — where either form step reads as too
42
- heavy for a line of running text. It is a prose rule weight, never an outline
43
- on a raised thing.
55
+ `--axi-border-hairline` (2px). It is a **rule** weight — a line drawn inside
56
+ content to separate parts of it, where either form step would turn a list of
57
+ numbers into a grid of boxes. That covers inline code and the list bullet in
58
+ `.axi-prose`, the rules between table rows, and the gridlines inside a plot.
59
+ It is never an outline on a raised thing: the two steps in the table above are
60
+ what raise a surface, and reaching for the hairline instead is how a panel
61
+ stops looking raised.
62
+
63
+ There is exactly one element drawn in the line ink rather than on a surface,
64
+ and it is named here so it stays an exception rather than becoming a habit:
65
+ `.axi-tooltip` is filled with `--axi-ink-line` itself. A thing cannot be
66
+ outlined in the colour it is already made of, and a block in that same ink
67
+ under a box already made of it reads as the box being thicker rather than
68
+ raised — so the tooltip takes a hairline in `--axi-rule` to hold its edge
69
+ against the page, and carries no block. Nothing else may use that reasoning.
44
70
 
45
71
  **What is mechanically enforced.** `tests/tokens.test.mjs` enforces both
46
72
  columns:
@@ -52,18 +78,24 @@ columns:
52
78
  `outline`/`outline-width` are checked the same way as `border`
53
79
  (`outline-offset` and `outline-color` are not weight properties and are
54
80
  untouched).
55
- - *Offset* — every `box-shadow` in a component file must be exactly
56
- `<offset> <offset> 0 var(--axi-ink-line)`, with the offset drawn from an
57
- enumerated list of four tokens: the two resting steps above, plus the two
58
- hover deepenings rule 4 describes (`--axi-offset-panel-hover` 10px,
81
+ - *Offset* — the block is composed once, in `tokens.css`, as one of four
82
+ `--axi-shadow-*` tokens, and each must be exactly
83
+ `<offset> <offset> 0 var(--axi-ink-line)` with the offset drawn from an
84
+ enumerated list of four: the two resting steps above, plus the two hover
85
+ deepenings rule 4 describes (`--axi-offset-panel-hover` 10px,
59
86
  `--axi-offset-control-hover` 6px). That is what rules out a blur, a spread,
60
- an invented offset and a shadow in any colour but the ink line.
61
- `filter: drop-shadow(...)` and `text-shadow` — the two other CSS properties
62
- that can draw the same blurred look — are forbidden outright, since nothing
63
- in this language legitimately reaches for either.
87
+ an invented offset and a shadow in any colour but the ink line. A component
88
+ file then names one of the four — `box-shadow: var(--axi-shadow-panel)` —
89
+ and may write nothing else in a `box-shadow`. Two checks rather than one,
90
+ because either alone is hollow: the shape check protects the four
91
+ definitions, the naming check stops a component composing its own block
92
+ beside them. `filter: drop-shadow(...)` and `text-shadow` — the two other
93
+ CSS properties that can draw the same blurred look — are forbidden
94
+ outright, since nothing in this language legitimately reaches for either.
64
95
  - *No local escape hatch* — the form tokens themselves
65
96
  (`--axi-border-panel`, `--axi-border-control`, `--axi-border-hairline`,
66
- `--axi-offset-panel`, `--axi-offset-control`, and their `-hover` variants)
97
+ `--axi-offset-panel`, `--axi-offset-control`, their `-hover` variants, and
98
+ the four `--axi-shadow-*` blocks composed from them)
67
99
  may be **declared** only in `tokens.css`. A component file redeclaring one
68
100
  of these on itself would change the value the border/offset checks above
69
101
  are silently trusting, without changing the `var()` text those checks read
@@ -173,8 +205,12 @@ than framing it down the left edge. A full-height stripe runs the height of the
173
205
  box, so it reads as the box's border: five cards in a row become five coloured
174
206
  frames, and the colour stops saying anything about any one number. A cap sits
175
207
  directly over the reading it is a verdict on, identifies it once, and then gets
176
- out of the way. Under rule 3 the cap is drawn at the panel weight; the card
177
- itself keeps its plain ink outline at the control weight.
208
+ out of the way. Under rule 3 both the cap's edge and the card's own outline are
209
+ drawn at the panel weight, because the card is a raised surface and rule 3
210
+ pairs the panel border with the panel offset the card already carries. A card
211
+ outlined at the control weight would pair a 3px border with a 6px offset —
212
+ a third step in everything but name — and would put a thin frame around a
213
+ heavier bar.
178
214
 
179
215
  A switch is the same rule in a slot. Its track fills to assert the setting's
180
216
  status and is empty otherwise; the slug that moves is `--axi-ink-line` in both
@@ -236,6 +272,14 @@ Three bounds, and the rule is only sound with all three:
236
272
  carries a 6px block. A second 6px block nested in the first reads as two
237
273
  planes arguing; the 3px control step reads as the contents of a box. This is
238
274
  also the honest weight: each row is a control you press, not a surface.
275
+ - **Text on a status fill is `--axi-ink-on-fill`, not `--axi-ink-line`.** The
276
+ two hold the same near-black today, which is why this was easy to get wrong:
277
+ a chip that says `color: var(--axi-ink-line)` reads correctly and still means
278
+ the wrong thing. `--axi-ink-line` is *the colour a shape is outlined in*;
279
+ `--axi-ink-on-fill` is *the colour a word is written in when it sits on a
280
+ saturated fill*. They only have to diverge once — a theme that outlines in a
281
+ light colour — for the conflated spelling to put light text on a bright chip.
282
+ `--axi-accent-ink` is the same distinction for the accent fill specifically.
239
283
  - **The outline stays `--axi-ink-line`.** Status goes on a filled shape inside
240
284
  the row — the icon tile, a chip — and never on the row's own edge. Colouring
241
285
  the edge is exactly the full-height stripe rule 5 rejects: five states become
@@ -328,6 +372,31 @@ If an app picks an accent dark enough that near-black text on it fails
328
372
  contrast, it also sets `--axi-accent-ink: var(--axi-text)`. It should not edit
329
373
  components.
330
374
 
375
+ ### Layer stack
376
+
377
+ Every `z-index` in this language is one of the layers below, and a component
378
+ that needs to sit above something picks its layer here rather than inventing a
379
+ number. A number not in this table is a component shouting over the stack
380
+ instead of joining it.
381
+
382
+ | Layer | z-index | What sits here |
383
+ |---|---|---|
384
+ | Sticky chrome | 40 | `.axi-mast` |
385
+ | Popovers | 41 | `.axi-menu__pop`, `.axi-picker__pop` |
386
+ | Scrim | 50 | `.axi-scrim` |
387
+ | Drawer | 51 | `.axi-drawer` |
388
+ | Toasts | 60 | `.axi-toasts` |
389
+ | Tooltip | 70 | `.axi-tooltip` |
390
+ | Modal | top layer | `dialog.axi-modal`, promoted by `showModal()` |
391
+
392
+ The modal has no number on purpose. A `<dialog>` opened with `showModal()` is
393
+ promoted to the browser's top layer, which sits above every `z-index` there is;
394
+ writing a number in that row would describe a competition the modal is not in.
395
+
396
+ A negative `z-index` inside a component's own `isolation` context — the sigil's
397
+ backing shape — is not a layer and is not listed. It is invisible outside the
398
+ component that owns it.
399
+
331
400
  ## Light mode
332
401
 
333
402
  Not shipped. The system is *structured* for it: no component contains a colour
@@ -335,6 +404,9 @@ literal, so a light theme is a second palette block, not a rewrite. It is not
335
404
  a token swap either — the saturated inks that read as vivid on near-black go
336
405
  washed out on white and would need retuning.
337
406
 
407
+ When it does ship it ships as a theme, under the section below, with the same
408
+ 1:1 obligation: light mode does not get a component the dark language lacks.
409
+
338
410
  ## The official accents
339
411
 
340
412
  `--axi-accent` is the per-app theming surface, and the family now agrees on
@@ -354,12 +426,69 @@ dist/axi.css.
354
426
  The default remains `#ffc53d` Axi Gold, declared in tokens.css: an app that
355
427
  sets no `data-axi-accent` is gold, and correctly themed.
356
428
 
429
+ ## Themes
430
+
431
+ The palette in `tokens.css` is not "the default theme". It is the language, and
432
+ a theme is a repaint of it. Everything rules 1-11 describe — the outline, the
433
+ block, what a fill means, what the cool ink is reserved for — is defined once,
434
+ there, and a theme inherits all of it. Anything a theme cannot say by restating
435
+ a token is not a theme; it is a change to the language, and it goes through the
436
+ rules above like any other.
437
+
438
+ A theme is a generated stylesheet, `dist/themes/<id>.css`, built the way
439
+ `dist/accents.css` is: `themes/<id>.json` is the source of truth and the build
440
+ emits one `[data-axi-theme="<id>"]` block of custom properties, no structural
441
+ CSS. That makes `themes/*.json` the third sanctioned home for a colour literal,
442
+ after `tokens.css` and `accents.json`, and for the same reason as the second —
443
+ it is data the build generates from, not stylesheet source. Hand-editing
444
+ `dist/themes/glass.css` is exactly as wrong as hand-editing `dist/axi.css`. A consumer imports it beside `axi.css` and sets
445
+ `data-axi-theme` on its root element. That is the whole integration — which is
446
+ the point. A site gets the dark language by default and a different one by
447
+ adding an attribute, and in neither case does it author, carry or maintain a
448
+ line of theme CSS of its own.
449
+
450
+ **A theme mirrors the main theme one-for-one.** In both directions:
451
+
452
+ - **Nothing added.** No token a theme invents, no selector but its own root
453
+ hook, and above all no component that exists only under a theme. There is no
454
+ `.axi-panel--glass`, no glass-only card, no variant that appears when the
455
+ attribute is set. A component that is worth having is worth having in the
456
+ language; one that only makes sense translucent is a component the language
457
+ does not have.
458
+ - **Nothing dropped.** A theme may not leave a token unset and a component
459
+ unpainted. Every component renders under every theme, which is what makes the
460
+ attribute a swap rather than a migration — a site can set it, unset it, or
461
+ offer the choice to its users, and no markup changes either way.
462
+
463
+ The consequence worth stating plainly: **a new theme capability costs a
464
+ main-theme token first.** A glass theme wants a `backdrop-filter`; it does not
465
+ get to introduce one. `--axi-surface-filter` is declared in `tokens.css` with an
466
+ inert default (`none`), the surfaces read it unconditionally, and the theme
467
+ restates it. The main theme is unchanged in appearance and the hook is part of
468
+ the language rather than part of the theme. Every theme pays this toll, and it
469
+ is what keeps the mirror true: a token the theme could set that the main theme
470
+ had never heard of is the first step back toward theme-only components.
471
+
472
+ Rules 1 and 2 name the only relief a theme gets, and it is confined to the
473
+ surface layer: a theme may put a gradient on a surface and may hold a surface
474
+ token at partial opacity. It may not mute an ink. Read those two rules for why.
475
+
476
+ **What is mechanically enforced.** `tests/themes.test.mjs` reads every
477
+ `dist/themes/*.css` and checks the mirror rather than trusting it: the file
478
+ contains exactly one rule, its selector is `[data-axi-theme="<id>"]` for the
479
+ `<id>` in its own filename, every declaration in it is a custom property with a
480
+ non-empty value, and every property it declares is already declared in
481
+ `tokens.css`. A theme-only component fails the first check, because drawing one
482
+ takes a second selector. An invented token fails the last. The suite passes
483
+ vacuously while no theme exists, and binds the moment the first file lands.
484
+
357
485
  ## Adding a component
358
486
 
359
487
  1. Which rule justifies it? If none, write the rule first or stop.
360
488
  2. Build it from the existing primitives. A shell that redefines `.axi-panel`
361
489
  instead of using it will drift the first time the panel changes.
362
- 3. No colour literals. No third form step.
490
+ 3. No colour literals. No third form step. No theme-only variant — see
491
+ [Themes](#themes).
363
492
  4. Add it to the gallery, and check it with the accent switcher — if it does
364
493
  not follow the accent, it hard-coded something.
365
494
  5. `npm run build` and commit `dist/axi.css` with your source change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/axi-design",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -17,11 +17,12 @@
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
20
- "//exports": "Two entry points, and both are stylesheets. Consumers import the path rather than the package root because there is no JavaScript here to be a default export - `import '@axiapps/axi-design/axi.css'` says what it does, and a bare `import '@axiapps/axi-design'` resolving to a stylesheet would not. ./tokens.css is the palette without the components, for an app that already draws its own components through its own variables and wants to point them at ours: it is the whole language for a consumer like that, and copying the token block by hand is the one way those values are guaranteed to drift.",
20
+ "//exports": "Every entry point is a stylesheet. Consumers import the path rather than the package root because there is no JavaScript here to be a default export - `import '@axiapps/axi-design/axi.css'` says what it does, and a bare `import '@axiapps/axi-design'` resolving to a stylesheet would not. ./tokens.css is the palette without the components, for an app that already draws its own components through its own variables and wants to point them at ours: it is the whole language for a consumer like that, and copying the token block by hand is the one way those values are guaranteed to drift. ./themes/*.css is a subpath pattern rather than one entry per theme, so adding a theme is adding themes/<id>.json and nothing else - an entry that has to be remembered is an entry that will be forgotten, and the symptom would be an import that resolves in this repo and not from an install.",
21
21
  "exports": {
22
22
  "./axi.css": "./dist/axi.css",
23
23
  "./tokens.css": "./src/tokens.css",
24
24
  "./accents.css": "./dist/accents.css",
25
+ "./themes/*.css": "./dist/themes/*.css",
25
26
  "./accents.json": "./accents.json",
26
27
  "./package.json": "./package.json"
27
28
  },
@@ -29,19 +30,22 @@
29
30
  "sideEffects": [
30
31
  "*.css"
31
32
  ],
32
- "//files": "dist/ is the artifact; src/ and docs/ ride along because RULES.md is the reason any of it is shaped the way it is, and a consumer reading a component wants it next to them. No tests, no gallery. LICENSE and README are included by npm regardless of this list.",
33
+ "//files": "dist/ is the artifact; src/ rides along because a consumer reading a component wants the source next to them; docs/RULES.md rides along because it is the reason any of it is shaped the way it is. The rest of docs/ - the manifest, the generator and the guides - builds the site and is no use to a consumer. No tests, no gallery. LICENSE and README are included by npm regardless of this list.",
33
34
  "files": [
34
35
  "dist",
35
36
  "src",
36
- "docs",
37
+ "docs/RULES.md",
37
38
  "accents.json",
38
39
  "README.md"
39
40
  ],
40
41
  "scripts": {
41
42
  "build": "node scripts/build.mjs",
43
+ "docs": "node scripts/site.mjs",
44
+ "serve": "node scripts/serve.mjs",
42
45
  "test": "vitest run"
43
46
  },
44
47
  "devDependencies": {
48
+ "marked": "^18.0.14",
45
49
  "vitest": "^2.1.0"
46
50
  }
47
51
  }
@@ -0,0 +1,67 @@
1
+ /* axi design language - feedback.
2
+ Things that report on work, and things that report on how work went. Rule
3
+ 11 governs the first kind and is the one rule in this language described as
4
+ non-negotiable: an indicator of work may animate only `transform` and
5
+ `opacity`, because those are the two the compositor runs off the main
6
+ thread. A spinner animated by layout freezes with the work it reports on,
7
+ and a frozen spinner tells the reader the app crashed at the exact moment
8
+ it was working hardest. tests/motion.test.mjs enforces it. */
9
+
10
+ /* ---------- spinner ---------- */
11
+ /* The family motif, turning. A square rotated 45deg is the diamond, so the
12
+ loop runs 45deg -> 405deg: one full turn that begins and ends on the
13
+ motif rather than on a square.
14
+ That start and end also make it correct under reduced motion for free.
15
+ base.css only shortens the animation to one .01ms iteration; it never sets
16
+ animation-fill-mode, so once that instant run finishes the element reverts
17
+ to its specified transform: rotate(45deg) - the motif at rest, and visually
18
+ identical to where the loop begins. */
19
+ @keyframes axi-spin { from { transform: rotate(45deg); } to { transform: rotate(405deg); } }
20
+ .axi-spinner {
21
+ display: inline-block;
22
+ flex: none;
23
+ width: var(--axi-spinner-size, 20px);
24
+ height: var(--axi-spinner-size, 20px);
25
+ border: var(--axi-border-control) solid var(--axi-accent);
26
+ border-radius: var(--axi-radius-sm);
27
+ transform: rotate(45deg);
28
+ animation: axi-spin 1.1s linear infinite;
29
+ }
30
+
31
+ /* ---------- indeterminate meter ---------- */
32
+ /* The .axi-meter that does not know how far along it is. The fill keeps a
33
+ fixed width and travels; `width` is never animated, which is the whole of
34
+ rule 11. --axi-meter-v is ignored in this mode - there is no value to
35
+ express, which is what indeterminate means. */
36
+ @keyframes axi-meter-busy {
37
+ from { transform: translateX(-100%); }
38
+ to { transform: translateX(400%); }
39
+ }
40
+ .axi-meter--busy .axi-meter__fill {
41
+ width: 25%;
42
+ animation: axi-meter-busy 1.3s ease-in-out infinite;
43
+ }
44
+ /* base.css only ever sets animation-duration and animation-iteration-count
45
+ under reduced motion; it never touches animation-fill-mode. The shorthand
46
+ above resets animation-fill-mode to its default, none, so once the .01ms
47
+ run finishes the element reverts to its specified style rather than
48
+ holding the keyframe it ended on - here that is transform: none and the
49
+ fill's own width: 25%, a left-anchored quarter-width bar. That is already
50
+ the right thing to show: visible, and unmistakably not finished. This rule
51
+ states transform: none explicitly rather than relying on the revert, so
52
+ the "at rest" case is not accidental. */
53
+ @media (prefers-reduced-motion: reduce) {
54
+ .axi-meter--busy .axi-meter__fill { transform: none; }
55
+ }
56
+
57
+ /* ---------- notice status ---------- */
58
+ /* Rule 5: the icon is the part of a notice that asserts something, so the
59
+ status lives there. The body text stays in the reading ink at every status
60
+ - a whole paragraph in the danger ink is the tinted-everything failure rule
61
+ 2 exists to prevent, and it is unreadable besides. */
62
+ .axi-notice--ok .axi-notice__icon { background: var(--axi-ok); }
63
+ .axi-notice--warn .axi-notice__icon { background: var(--axi-warn); }
64
+ .axi-notice--danger .axi-notice__icon { background: var(--axi-danger); }
65
+ .axi-notice--ok b { color: var(--axi-ok); }
66
+ .axi-notice--warn b { color: var(--axi-warn); }
67
+ .axi-notice--danger b { color: var(--axi-danger); }
package/src/forms.css ADDED
@@ -0,0 +1,89 @@
1
+ /* axi design language - forms.
2
+ Real inputs, styled. Every control here is an actual <input> or <textarea>
3
+ with `appearance: none`, never a <div> wearing a class: a checkbox that is
4
+ not an <input> does not submit with its form, does not toggle from the
5
+ keyboard, and is not announced as a checkbox. The language styles controls;
6
+ it does not reimplement them.
7
+
8
+ Rule 3's flat case governs the geometry: a control inside a panel is
9
+ outlined and carries no block, exactly as .axi-input and .axi-switch
10
+ already are. Rule 5 governs the state: the fill is what says "on".
11
+
12
+ The focus ring is deliberately absent from this file. base.css draws one
13
+ page-wide on :focus-visible, and appearance: none does not remove it - it
14
+ removes the browser's own. Suppressing the outline anywhere here would make
15
+ every control in it unusable by keyboard. */
16
+
17
+ /* ---------- checkbox and radio ---------- */
18
+ /* One box, two marks. --axi-radius is 0, so both are square and a round radio
19
+ would be the only rounded shape in the language - the first place a flat
20
+ outlined form starts looking like some other framework's. So they are told
21
+ apart by their mark instead: the checkbox gets a check, the radio gets the
22
+ family diamond (rule 7).
23
+
24
+ Both read the same two knobs on purpose. A form holding checkboxes and
25
+ radios together should not need two properties set to the same value to
26
+ keep its controls the same size. */
27
+ .axi-check,
28
+ .axi-radio {
29
+ appearance: none;
30
+ -webkit-appearance: none;
31
+ margin: 0;
32
+ flex: none;
33
+ width: var(--axi-check-size, 22px);
34
+ height: var(--axi-check-size, 22px);
35
+ display: inline-grid;
36
+ place-items: center;
37
+ background: var(--axi-ground);
38
+ border: var(--axi-border-control) solid var(--axi-ink-line);
39
+ border-radius: var(--axi-radius-sm);
40
+ cursor: pointer;
41
+ }
42
+ /* The mark is always in the DOM and revealed with opacity rather than being
43
+ created on :checked, so the box never changes size as it toggles. */
44
+ .axi-check::after {
45
+ content: "";
46
+ width: 55%;
47
+ height: 30%;
48
+ opacity: 0;
49
+ border-left: var(--axi-border-control) solid var(--axi-accent-ink);
50
+ border-bottom: var(--axi-border-control) solid var(--axi-accent-ink);
51
+ transform: translateY(-12%) rotate(-45deg);
52
+ }
53
+ .axi-check:checked { background: var(--axi-check-fill, var(--axi-accent)); }
54
+ .axi-check:checked::after { opacity: 1; }
55
+ /* The radio's mark is the diamond itself, so it is drawn in the fill rather
56
+ than on it - there is no filled box underneath it to sit on. It takes the
57
+ hairline outline rule 7's diamond wears at this scale (see the prose
58
+ bullet, src/prose.css, the other place a diamond this small is drawn) -
59
+ the mark is revealed with opacity, so the hairline is hidden along with
60
+ the fill in the unchecked state and never leaks out on its own. */
61
+ .axi-radio::after {
62
+ content: "";
63
+ width: 46%;
64
+ height: 46%;
65
+ opacity: 0;
66
+ background: var(--axi-check-fill, var(--axi-accent));
67
+ border: var(--axi-border-hairline) solid var(--axi-ink-line);
68
+ transform: rotate(45deg);
69
+ }
70
+ .axi-radio:checked::after { opacity: 1; }
71
+ .axi-check:disabled,
72
+ .axi-radio:disabled { cursor: not-allowed; opacity: .5; }
73
+
74
+ /* ---------- textarea ---------- */
75
+ /* Not a component: the same .axi-input a single-line field uses, with the two
76
+ things a <textarea> needs that an <input> does not. The font is restated
77
+ because a textarea does not inherit the page font in any browser, and a
78
+ form whose notes field is monospace while its name field is not is a bug
79
+ nobody files and everybody sees.
80
+
81
+ Resizing is vertical only. A textarea dragged wider than its field breaks
82
+ the column its form is laid out in, and the reader who did it has no way
83
+ to discover that the layout is not at fault. */
84
+ textarea.axi-input {
85
+ min-height: var(--axi-textarea-h, 90px);
86
+ resize: vertical;
87
+ font-family: var(--axi-sans);
88
+ line-height: 1.5;
89
+ }
@@ -10,9 +10,19 @@
10
10
  us to ship a component for it. */
11
11
  .axi-panel {
12
12
  background: var(--axi-surface);
13
+ /* What the surface does to whatever is behind it. `none` in the main theme,
14
+ which is inert: unlike a transform, `backdrop-filter: none` establishes no
15
+ containing block, so rule 4's fixed-descendant trap is not reopened here.
16
+ A theme that makes the surfaces translucent restates this token and the
17
+ blur arrives everywhere at once. Carried only by the surfaces something
18
+ can actually be behind - a panel, a popover, an overlay, the scrim. A
19
+ chip, a switch, a page number and a card glyph sit directly on their
20
+ parent's fill with nothing but that fill behind them, so the filter would
21
+ cost a compositor layer each to blur a flat colour. */
22
+ backdrop-filter: var(--axi-surface-filter);
13
23
  border: var(--axi-border-panel) solid var(--axi-ink-line);
14
24
  border-radius: var(--axi-radius);
15
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
25
+ box-shadow: var(--axi-shadow-panel);
16
26
  /* A panel owns its padding so every consumer doesn't have to invent one;
17
27
  override per instance with --axi-panel-pad where a tighter or looser
18
28
  fit is genuinely called for. */
@@ -39,13 +49,13 @@
39
49
  vision and costs no colour. */
40
50
  .axi-btn:hover {
41
51
  color: var(--axi-text);
42
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
52
+ box-shadow: var(--axi-shadow-control);
43
53
  transform: translate(-2px, -2px);
44
54
  }
45
55
  .axi-btn--primary {
46
56
  background: var(--axi-accent);
47
57
  color: var(--axi-accent-ink);
48
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
58
+ box-shadow: var(--axi-shadow-control);
49
59
  }
50
60
  /* The one button that rests with a block already under it, so the generic
51
61
  hover's translate alone would move element and block together and leave the
@@ -53,7 +63,7 @@
53
63
  rule 4 warns about. It lifts the way a panel does: the block deepens too. */
54
64
  .axi-btn--primary:hover {
55
65
  color: var(--axi-accent-ink);
56
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
66
+ box-shadow: var(--axi-shadow-control-hover);
57
67
  }
58
68
  .axi-btn--ghost { background: transparent; }
59
69
  .axi-btn--dashed { background: transparent; border-style: dashed; }
@@ -79,13 +89,13 @@
79
89
  }
80
90
  .axi-pill:hover {
81
91
  color: var(--axi-text);
82
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
92
+ box-shadow: var(--axi-shadow-control);
83
93
  transform: translate(-2px, -2px);
84
94
  }
85
95
  .axi-pill[aria-pressed="true"] {
86
96
  background: var(--axi-pill-fill, var(--axi-accent));
87
97
  color: var(--axi-accent-ink);
88
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
98
+ box-shadow: var(--axi-shadow-control);
89
99
  }
90
100
  /* Spelled out rather than left to source order: `.axi-pill:hover` and
91
101
  `.axi-pill[aria-pressed="true"]` have identical specificity, so without this
@@ -95,7 +105,7 @@
95
105
  .axi-pill[aria-pressed="true"]:hover {
96
106
  background: var(--axi-pill-fill, var(--axi-accent));
97
107
  color: var(--axi-accent-ink);
98
- box-shadow: var(--axi-offset-control-hover) var(--axi-offset-control-hover) 0 var(--axi-ink-line);
108
+ box-shadow: var(--axi-shadow-control-hover);
99
109
  transform: translate(-2px, -2px);
100
110
  }
101
111
 
@@ -169,9 +179,9 @@
169
179
  letter-spacing: var(--axi-ls-micro);
170
180
  text-transform: uppercase;
171
181
  }
172
- .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-line); }
173
- .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-line); }
174
- .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-line); }
182
+ .axi-chip--ok { background: var(--axi-ok); color: var(--axi-ink-on-fill); }
183
+ .axi-chip--warn { background: var(--axi-warn); color: var(--axi-ink-on-fill); }
184
+ .axi-chip--danger { background: var(--axi-danger); color: var(--axi-ink-on-fill); }
175
185
  .axi-chip--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
176
186
  .axi-chip--meta {
177
187
  background: transparent;
@@ -267,7 +277,7 @@
267
277
  .axi-picker__btn:hover,
268
278
  .axi-picker__btn[aria-expanded='true'] {
269
279
  color: var(--axi-text);
270
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
280
+ box-shadow: var(--axi-shadow-control);
271
281
  transform: translate(-2px, -2px);
272
282
  }
273
283
 
@@ -288,7 +298,8 @@
288
298
  border: var(--axi-border-panel) solid var(--axi-ink-line);
289
299
  border-radius: var(--axi-radius);
290
300
  background: var(--axi-surface-raised);
291
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
301
+ backdrop-filter: var(--axi-surface-filter);
302
+ box-shadow: var(--axi-shadow-panel);
292
303
  }
293
304
  .axi-select option {
294
305
  display: flex; align-items: center; gap: 9px;
@@ -359,12 +370,13 @@
359
370
  max-height: 340px; overflow-y: auto;
360
371
  padding: 6px;
361
372
  background: var(--axi-surface-raised);
373
+ backdrop-filter: var(--axi-surface-filter);
362
374
  border: var(--axi-border-panel) solid var(--axi-ink-line);
363
375
  border-radius: var(--axi-radius);
364
376
  /* The block falls outside the popover's own box. An ancestor that clips -
365
377
  a scrolling pane, a panel with overflow: hidden - eats it, and the only
366
378
  shadow on the screen is the one that goes missing. */
367
- box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
379
+ box-shadow: var(--axi-shadow-panel);
368
380
  }
369
381
  .axi-picker__pop[hidden] { display: none; }
370
382
  /* A popover cannot always live beside its trigger. Inside a pane that scrolls
@@ -405,3 +417,31 @@
405
417
  .axi-picker__opt:focus-visible { outline-offset: -3px; }
406
418
  .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
407
419
  .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }
420
+
421
+ /* ---------- avatar ---------- */
422
+ /* A square, because a circle is the one shape this language does not have:
423
+ --axi-radius is 0, and a round avatar would be the only rounded thing on
424
+ the page. It follows the radius scale like everything else, so a consumer
425
+ who wants round avatars sets --axi-radius-sm and gets rounded controls
426
+ everywhere - which is the honest version of the request.
427
+ Flat: outlined, no block. An avatar is content inside a panel, the same as
428
+ .axi-stat and .axi-table__rank, not a thing raised off it. */
429
+ .axi-avatar {
430
+ width: var(--axi-avatar-size, 40px);
431
+ height: var(--axi-avatar-size, 40px);
432
+ flex: none;
433
+ display: grid;
434
+ place-items: center;
435
+ overflow: hidden;
436
+ background: var(--axi-ground);
437
+ color: var(--axi-text-dim);
438
+ border: var(--axi-border-control) solid var(--axi-ink-line);
439
+ border-radius: var(--axi-radius-sm);
440
+ font: var(--axi-t-label);
441
+ letter-spacing: var(--axi-ls-label);
442
+ text-transform: uppercase;
443
+ }
444
+ .axi-avatar--accent { background: var(--axi-accent); color: var(--axi-accent-ink); }
445
+ /* object-fit rather than a background-image, so the <img>'s alt text survives
446
+ and a broken source is visible rather than silently blank. */
447
+ .axi-avatar__img { width: 100%; height: 100%; object-fit: cover; display: block; }
package/src/prose.css CHANGED
@@ -57,7 +57,7 @@
57
57
  background: var(--axi-ground);
58
58
  border: var(--axi-border-control) solid var(--axi-ink-line);
59
59
  border-radius: var(--axi-radius-sm);
60
- box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
60
+ box-shadow: var(--axi-shadow-control);
61
61
  }
62
62
  .axi-prose pre code { background: none; border: 0; padding: 0; font-size: 12.5px; line-height: 1.6; }
63
63