@axiapps/axi-design 1.8.0 → 1.10.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,333 @@
1
+ # axi-design documentation site — design
2
+
3
+ **Date:** 2026-09-23
4
+ **Status:** approved, ready for planning
5
+ **Scope:** round one of two. This spec covers the documentation site only. Adding
6
+ missing elements (modal, alert, breadcrumb, pagination, accordion, checkbox,
7
+ radio, textarea, progress, toast, avatar, spinner, …) is round two and gets its
8
+ own spec.
9
+
10
+ ## Intent
11
+
12
+ Today `axi-design` publishes a single `index.html` that scrolls through six
13
+ sections of examples. It proves the components exist; it does not explain them,
14
+ cannot be deep-linked, and has no mechanism preventing a component from going
15
+ undocumented. This project turns that page into a documentation site in the
16
+ mould of Bootstrap's — per-component pages, narrative guides, search — built
17
+ entirely out of axi's own components.
18
+
19
+ ### Audiences, all three weighted
20
+
21
+ 1. **The author, across the suite.** Building an app, needs the exact class
22
+ names and copyable markup in seconds. Optimises for density, search and
23
+ deep links.
24
+ 2. **An LLM agent writing axi-styled UI.** Needs the complete class inventory,
25
+ the knob surface, and the rules it must not break. Optimises for
26
+ completeness and machine-readable structure (`llms.txt`).
27
+ 3. **An outside adopter arriving from npm.** Needs to see what it looks like
28
+ and decide. Optimises for a landing page that shows rather than tells.
29
+
30
+ ### Success criteria
31
+
32
+ - Every `.axi-*` class in `src/` is reachable from a documentation page, and
33
+ this is enforced by a test rather than by discipline.
34
+ - The markup shown in a code block is, by construction, the markup that
35
+ produced the demo beside it. Drift is structurally impossible.
36
+ - The site's own chrome uses only axi components plus a quarantined
37
+ `.docs-*` layer. The site is evidence the language can build a real site.
38
+ - `docs/RULES.md` remains the single source of truth for the language; the
39
+ site renders it rather than restating it.
40
+ - Nothing a consumer downloads changes. `dist/axi.css`, the npm tarball and
41
+ every published `/vN/axi.css` are untouched by this work.
42
+
43
+ ### Non-goals
44
+
45
+ - No new components, no changes to any file in `src/`. Gaps the work exposes
46
+ are recorded for round two, not filled.
47
+ - No light theme, no i18n, no versioned documentation (the site documents the
48
+ current version; only the CSS artifacts are versioned, as today).
49
+ - No runtime dependency, ever. The only new dependency is `marked`, build-time
50
+ and `devDependencies` only.
51
+
52
+ ## Approach
53
+
54
+ Rejected: a static site generator (11ty/VitePress). It would theme the docs in
55
+ its own design system, which for a design language forfeits the site's main
56
+ argument, and it imports a toolchain into a repo with one devDependency.
57
+
58
+ Rejected: hand-written HTML per page. No forcing function keeping components
59
+ documented, and the shared chrome gets copy-pasted into thirty files — a cost
60
+ that compounds exactly when round two doubles the page count.
61
+
62
+ **Chosen:** a generator owned by this repo, driven by a component manifest, with
63
+ Markdown for narrative prose. The manifest gives enforcement and machine-readable
64
+ output; Markdown keeps the guides pleasant enough to actually write.
65
+
66
+ ## Site map
67
+
68
+ ```
69
+ / Landing — the look, the pitch, install in three lines
70
+ /start/ Getting started: the three consumption modes, set an accent
71
+ /rules/ docs/RULES.md, rendered
72
+ /theming/ Tokens, the accent list, the per-instance knob table
73
+ /components/ Index: every component grouped by layer, with preview tiles
74
+ /components/<id>/ One page per component family (~30 pages)
75
+ /gallery/ The kitchen sink — today's index.html, everything on one scroll
76
+ /llms.txt Exhaustive machine-readable reference
77
+ /search.json Client-side search index
78
+ /vN/axi.css Unchanged; staged from git tags exactly as today
79
+ ```
80
+
81
+ `/gallery/` is retained deliberately. It is the only view in which the system
82
+ can be judged as a whole, which thirty separate pages actively obscure. It stops
83
+ being the front door and becomes the smoke test.
84
+
85
+ `/rules/` renders `docs/RULES.md` through `marked` into `.axi-prose`. There is no
86
+ second copy. RULES.md continues to ship in the npm tarball and continues to be
87
+ what `tests/tokens.test.mjs` is written against.
88
+
89
+ ## The manifest
90
+
91
+ ```
92
+ docs/manifest/
93
+ index.mjs combines the layer modules, validates entry shape
94
+ primitives.mjs layout.mjs shells.mjs data.mjs prose.mjs utilities.mjs
95
+ knobs.mjs the per-instance knob table, as data
96
+ ```
97
+
98
+ `.mjs` rather than JSON so example markup can be a readable template literal and
99
+ entries can carry comments.
100
+
101
+ ### Entry shape
102
+
103
+ ```js
104
+ {
105
+ id: 'meter', // URL segment; /components/meter/
106
+ name: 'Meter',
107
+ layer: 'data', // sidebar group; see note below
108
+ classes: ['.axi-meter', '.axi-meter__fill', '.axi-meter-list', …],
109
+ summary: 'One or two sentences. Used on the page, the index tile and llms.txt.',
110
+ rules: [1, 2], // clause numbers in docs/RULES.md
111
+ knobs: ['--axi-meter-v', '--axi-meter-h', '--axi-series'],
112
+ notes: `Optional Markdown for anything the summary cannot carry.`,
113
+ examples: [
114
+ { title: 'A single meter', note: 'Optional one-liner, right-aligned', html: `…` },
115
+ ],
116
+ }
117
+ ```
118
+
119
+ **`layer` is a documentation concern, not a source-file fact.** The `src/` split
120
+ is by cascade order, not by category, and the two genuinely differ:
121
+
122
+ | Class | Lives in | Documented under |
123
+ |---|---|---|
124
+ | `.axi-panel` | `primitives.css` | Layout |
125
+ | `.axi-quote` | `shells.css` | Prose |
126
+ | `.axi-notice`, `.axi-tooltip`, `.axi-eyebrow`, `.axi-sigil` | `shells.css` | Primitives |
127
+ | `.axi-sr-only` | `base.css` | Utilities |
128
+
129
+ `layer` is therefore declared per entry and never inferred from the file. A
130
+ `utilities` group exists for classes that are real and documented but belong to
131
+ no visual family.
132
+
133
+ ### Single-sourcing of examples
134
+
135
+ Each `examples[].html` string is rendered twice by the generator: raw into the
136
+ live demo, and HTML-escaped-then-highlighted into the code block beneath it.
137
+ This is the central mechanism of the design — the copyable code cannot drift
138
+ from the rendered demo because there is only one string.
139
+
140
+ ## The generator
141
+
142
+ `scripts/site.mjs`, new, separate from `scripts/build.mjs`. `build.mjs` continues
143
+ to build the shipped artifact and is not modified except where noted under
144
+ "README knob table". Different lifecycles, and nothing in the site generator can
145
+ affect what a consumer downloads.
146
+
147
+ ```
148
+ docs/site/
149
+ shell.mjs page chrome: mast, sidebar, TOC, footer
150
+ render.mjs component-page and index-page renderers
151
+ markdown.mjs marked wrapper → .axi-prose
152
+ highlight.mjs build-time HTML tokeniser
153
+ docs.css docs-only chrome, .docs-* prefix
154
+ search.js client-side search over search.json
155
+ copy.js code-block copy buttons
156
+ ```
157
+
158
+ Markdown pages may embed generated content through a placeholder line — a
159
+ lone `<!-- axi:knobs -->` or `<!-- axi:accents -->` in the source — which the
160
+ generator replaces with the rendered table. This is how `/theming/` shows the
161
+ knob and accent tables without a hand-maintained second copy, and it is the
162
+ same mechanism as the README markers. An unrecognised `axi:` placeholder is a
163
+ build error rather than a silent passthrough.
164
+
165
+ Outputs to `_site/`: directory-style URLs (`_site/components/meter/index.html`),
166
+ plus `search.json`, `llms.txt`, and copies of `dist/` and `docs/site/*.{css,js}`.
167
+
168
+ ### Base-path handling
169
+
170
+ The site is served from `darkharasho.github.io/axi-design/`, not from a domain
171
+ root, while local preview is served from `/`. The generator takes a single
172
+ `BASE` constant (`/axi-design/` for Pages, `/` for `npm run serve`) and every
173
+ emitted href passes through one `url()` helper. A test asserts no emitted page
174
+ contains a raw `href="/` or `src="/` outside that helper. This is the most
175
+ likely thing to ship silently broken, so it is checked directly.
176
+
177
+ ### Syntax highlighting
178
+
179
+ Build-time, hand-rolled in `highlight.mjs`, roughly 30 lines. HTML only — the
180
+ one language the samples are written in. Four token classes, coloured from
181
+ existing tokens:
182
+
183
+ | Token | Class | Ink |
184
+ |---|---|---|
185
+ | Tag name | `.t-tag` | `--axi-meta` |
186
+ | Attribute name | `.t-attr` | `--axi-accent` |
187
+ | Attribute value | `.t-str` | `--axi-ok` |
188
+ | Punctuation | `.t-punc` | `--axi-text-faint` |
189
+
190
+ A Prism/Shiki dependency is not justified for HTML, and would be a runtime cost
191
+ paid by every visitor for a transform that can happen once at build time.
192
+
193
+ ### Docs-only CSS is quarantined
194
+
195
+ `docs/site/docs.css` holds the sidebar, TOC, code block and example-block
196
+ styles under a `.docs-*` prefix. It is never read by `build.mjs`, never enters
197
+ `dist/`, and is not in the npm `files` list.
198
+
199
+ Deliberate side effect: **`docs.css` is the shopping list for round two.**
200
+ Anything in it that proves generally useful becomes a candidate for promotion
201
+ into `src/`, having earned its place by being needed rather than by appearing
202
+ on another framework's feature list.
203
+
204
+ ## Page anatomy
205
+
206
+ ### Component page
207
+
208
+ Three columns: sidebar (grouped by `layer`, current page filled solid in the
209
+ accent — the language has no soft states, so "selected" is a block of ink),
210
+ content, and an "On this page" TOC. Below the layout breakpoint the TOC is
211
+ dropped and the sidebar collapses into a disclosure in the mast.
212
+
213
+ Content order:
214
+
215
+ 1. `.axi-eyebrow` with the layer name
216
+ 2. Title
217
+ 3. Lede — the `summary` field
218
+ 4. Class list as `.axi-chip--meta` chips, so the names being looked for appear
219
+ immediately below the title
220
+ 5. `notes`, if present, rendered as `.axi-prose`
221
+ 6. Examples — for each: title, optional right-aligned note, demo in a
222
+ panel-weight block, code block tucked against the panel's offset so the two
223
+ read as one object, copy button in the code block's corner
224
+ 7. **Knobs** — table sourced from `knobs.mjs`, filtered to this entry's `knobs`
225
+ 8. **Rules this answers** — each clause in `rules` rendered as `.axi-notice`
226
+ with the clause number in the icon slot, deep-linking to `/rules/#rule-<n>`.
227
+ `marked` derives heading ids from heading text, which would make the anchor
228
+ depend on the wording of a rule's title and break the link whenever a rule
229
+ is reworded. The Markdown renderer therefore overrides heading ids for
230
+ `## <n>. …` headings in RULES.md to the stable form `rule-<n>`.
231
+
232
+ Section 8 is what distinguishes this from a component list: every page closes by
233
+ pointing at the clauses that dictate why the component looks the way it does.
234
+
235
+ ### Chrome
236
+
237
+ The mast is `.axi-mast` / `.axi-brand` / `.axi-sigil` / `.axi-tabs` /
238
+ `.axi-search` / `.axi-select`, entirely existing components. The package version
239
+ sits in `.axi-brand__name`'s `<small>`, read from `package.json` at build time.
240
+ The accent switcher is the existing `gallery.js` behaviour, generalised to
241
+ every page and driven by `accents.json`. `gallery.js` is split accordingly: the
242
+ accent-switching portion moves to `docs/site/accent.js` and is loaded by every
243
+ page, while the demo-specific behaviour (menu, drawer, tooltip positioning)
244
+ stays in `gallery.js`, which is loaded only by `/gallery/`.
245
+
246
+ ### Landing page
247
+
248
+ One job: show, not tell. A hero composed of real components — panel, card with
249
+ its strip, meter, chip row — with the live accent switcher, the three-line
250
+ install directly beneath, and two buttons: "Get started" and "Components". The
251
+ philosophy gets one paragraph and a link to `/rules/`, not a wall of text.
252
+
253
+ ## The README knob table becomes generated
254
+
255
+ `docs/manifest/knobs.mjs` becomes the source of truth for the per-instance knob
256
+ table. `README.md` carries the table between HTML comment markers, regenerated
257
+ by the build, with a test asserting the file on disk matches what the manifest
258
+ produces. Today that table can drift from `src/` silently; after this it cannot.
259
+ This is the one place the work touches `build.mjs`.
260
+
261
+ ## Enforcement
262
+
263
+ New `tests/manifest.test.mjs`:
264
+
265
+ 1. Every `.axi-*` class defined in `src/` appears in some entry's `classes`.
266
+ An undocumented component fails the build. In round two, a new element
267
+ cannot land without its page.
268
+ 2. Every class used in any example's `html` exists in `src/`. Catches typos in
269
+ the docs and doc pages that outlive their CSS.
270
+ 3. Every `--axi-*` custom property read with a fallback in `src/` appears in
271
+ `knobs.mjs`.
272
+ 4. `README.md`'s knob table matches the table generated from `knobs.mjs`.
273
+ 5. Entry shape: unique `id`s, non-empty `summary`, `layer` from the known set,
274
+ `rules` referencing clause numbers that exist in `RULES.md`.
275
+
276
+ New `tests/site.test.mjs`:
277
+
278
+ 6. The generator emits every expected page for a given manifest.
279
+ 7. Example HTML is escaped in code blocks and never executed as markup there.
280
+ 8. Highlighting round-trips: stripping the emitted `<span>`s and unescaping
281
+ returns the original source string exactly.
282
+ 9. No emitted page contains a raw absolute `href="/` or `src="/`.
283
+
284
+ Existing `tests/tokens.test.mjs`, `tests/build.test.mjs` and
285
+ `tests/accents.test.mjs` are unchanged.
286
+
287
+ Per repository convention, vitest runs with `--maxWorkers=2`.
288
+
289
+ ## Build and deploy
290
+
291
+ ```
292
+ npm run build scripts/build.mjs src/*.css → dist/axi.css, dist/accents.css
293
+ npm run docs scripts/site.mjs manifest + md → _site/
294
+ npm run serve scripts/serve.mjs ~25 lines, no dependency, local preview at /
295
+ npm test vitest run
296
+ ```
297
+
298
+ `.github/workflows/pages.yml`: the current
299
+ `cp -r index.html gallery.js dist _site/` is replaced by `npm run docs`, which
300
+ builds `_site/` itself. The existing per-tag staging loop follows unchanged.
301
+ The guarantee that every published `/vN/axi.css` keeps resolving is untouched,
302
+ because those artifacts are read from git tags and not from the site build.
303
+
304
+ `.gitignore` gains `_site/`.
305
+
306
+ ## Implementation sequence
307
+
308
+ Each step is independently verifiable, and the second is expected to fail
309
+ loudly — that is its purpose.
310
+
311
+ 1. Generator skeleton, shell, `docs.css`, one hardcoded component page.
312
+ 2. `data.mjs` manifest + enforcement test 1. **Expect failures** naming
313
+ undocumented classes (`.axi-axis`, `.axi-legend`, `.axi-badge-count`,
314
+ `.axi-sigil`, `.axi-eyebrow` are the anticipated ones). Group them rather
315
+ than forcing them into a layer they do not belong to.
316
+ 3. Remaining manifest layers: primitives, layout, shells, prose, utilities.
317
+ 4. `knobs.mjs`, the knobs section, and the generated README table.
318
+ 5. Markdown pipeline: `/start/`, `/theming/`, `/rules/`.
319
+ 6. Landing page; move `index.html` to `/gallery/`.
320
+ 7. Search index and client search; `llms.txt`.
321
+ 8. `pages.yml`, `.gitignore`, `npm run serve`.
322
+
323
+ ## Risks
324
+
325
+ - **Base-path breakage** — the site works locally and 404s on Pages. Mitigated
326
+ by the single `url()` helper and test 9.
327
+ - **Manifest tedium** — thirty entries with examples is the bulk of the work.
328
+ Mitigated by porting the existing markup out of `index.html`, which is
329
+ already written and already correct.
330
+ - **Enforcement test 1 becoming an obstacle** — if a class legitimately should
331
+ not be documented, the answer is an explicit `utilities` entry, never an
332
+ exclusion list in the test. An exclusion list is the escape hatch this
333
+ repository's existing tests are written specifically to avoid.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiapps/axi-design",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "The design language for the axi suite — flat and outlined, dark, drawn in saturated ink.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/base.css CHANGED
@@ -59,6 +59,8 @@ a { color: inherit; }
59
59
  .axi-pill:hover,
60
60
  .axi-pill[aria-pressed="true"]:hover,
61
61
  .axi-select:hover,
62
+ .axi-picker__btn:hover,
63
+ .axi-picker__btn[aria-expanded="true"],
62
64
  .axi-card:hover,
63
65
  .axi-drawer__close:hover {
64
66
  transform: none !important;
package/src/data.css CHANGED
@@ -183,6 +183,42 @@
183
183
  background: var(--axi-series, var(--axi-accent));
184
184
  }
185
185
 
186
+ /* ---------- ticks ---------- */
187
+ /* A run of yes/no along a timeline: attended or missed, passed or failed,
188
+ shipped or skipped. This is rule 9 read the other way round - a bar has a
189
+ length because a quantity has one, and "no" has no length at all. Drawn as
190
+ a bar it has to be faked with a stub, and a stub reads as "a little bit",
191
+ which is the same lie as a faded fill. So every event here is one mark of
192
+ the same size, and what changes between them is only which ink it is drawn
193
+ in: rule 10's two series, the accent for the fact and the neutral ramp for
194
+ its absence.
195
+
196
+ Neither mark is outlined, which is the one place this departs from the
197
+ filled/empty pair the language reaches for first. At the size a run of
198
+ twenty sits in a table row the outline IS the mark - three pixels of edge
199
+ around one pixel of middle - so the pair would differ in nothing the eye
200
+ can resolve. A strip drawn large enough to outline is a bar chart, and
201
+ .axi-bars already is one.
202
+
203
+ Marks are a fixed width and never flex: a run of fourteen and a run of
204
+ three share a column in a table, and the short one has to read as a short
205
+ run rather than as fourteen fatter events. */
206
+ .axi-ticks {
207
+ display: inline-flex;
208
+ align-items: stretch;
209
+ gap: var(--axi-ticks-gap, 3px);
210
+ height: var(--axi-tick-h, 15px);
211
+ }
212
+ .axi-ticks__tick {
213
+ flex: none;
214
+ width: var(--axi-tick-w, 5px);
215
+ background: var(--axi-rule);
216
+ border-radius: var(--axi-radius-sm);
217
+ }
218
+ .axi-ticks__tick--on {
219
+ background: var(--axi-series, var(--axi-accent));
220
+ }
221
+
186
222
  /* ---------- plot ---------- */
187
223
  /* A frame for a line or an area, with its horizontal rules drawn in. The
188
224
  rules are hard stops in a repeating gradient, which is the select caret's
@@ -234,8 +234,11 @@
234
234
  /* ---------- select ---------- */
235
235
  /* The closed box is ours everywhere: strip the native control and draw the
236
236
  caret, so a select sits alongside the other controls as just another
237
- outlined chip instead of announcing the OS. */
238
- .axi-select {
237
+ outlined chip instead of announcing the OS. .axi-picker__btn is the same
238
+ box worn by a button instead of a <select>; the two share every
239
+ declaration here so a dropdown reads the same whichever half opens it. */
240
+ .axi-select,
241
+ .axi-picker__btn {
239
242
  appearance: none;
240
243
  padding: 10px 30px 10px 9px;
241
244
  border: var(--axi-border-control) solid var(--axi-ink-line);
@@ -256,7 +259,13 @@
256
259
  background-repeat: no-repeat;
257
260
  transition: transform .1s, box-shadow .1s;
258
261
  }
259
- .axi-select:hover {
262
+ /* The open trigger keeps the lift. Rule 4 gives the raise to a pointer, but a
263
+ disclosure that drops back flat the moment the pointer moves into the list
264
+ it opened severs the two: the lift is what says this box and that popover
265
+ are one control. */
266
+ .axi-select:hover,
267
+ .axi-picker__btn:hover,
268
+ .axi-picker__btn[aria-expanded='true'] {
260
269
  color: var(--axi-text);
261
270
  box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
262
271
  transform: translate(-2px, -2px);
@@ -265,7 +274,9 @@
265
274
  /* The popup stays OS chrome until a browser lets us style it. Where one does
266
275
  (Chromium's base-select), the list is drawn with the same outline and offset
267
276
  block as our own popovers, so both dropdown kinds read as one family; where
268
- it does not, the closed box above is still ours and the list is native. */
277
+ it does not, the closed box above is still ours and the list is native -
278
+ and if that native list is not good enough, .axi-picker below draws the
279
+ whole thing. */
269
280
  @supports (appearance: base-select) {
270
281
  /* base-select draws its own ::picker-icon, so the hand-drawn caret above
271
282
  would be a second arrow. */
@@ -299,3 +310,98 @@
299
310
  .axi-select option:checked { color: var(--axi-text); }
300
311
  .axi-select option::checkmark { content: "\2713"; color: var(--axi-accent); font-weight: 900; }
301
312
  }
313
+
314
+ /* ---------- picker ---------- */
315
+ /* The select's other half. Above, the native popup is left as OS chrome
316
+ wherever `appearance: base-select` is missing - and that is most places
317
+ today, including every Electron built on a Chromium older than the
318
+ property. What lands there is a raised list the language cannot reach: no
319
+ ink outline, no offset block, its own selection colour instead of the
320
+ accent. That is rule 3 broken by a box we do not own, so the fix is to stop
321
+ asking the OS to draw it. .axi-picker is a button and a popover wearing the
322
+ closed box and the list styling from the @supports branch above, so the two
323
+ kinds are the same dropdown and a consumer picks by what the platform has.
324
+
325
+ Prefer the native <select> where it works: it comes with keyboard handling,
326
+ typeahead, and a popup that can escape the window. This is what you use
327
+ when it does not.
328
+
329
+ Markup contract - the listbox pattern, and the popover carries the panel
330
+ weight because it is a raised surface, not a control:
331
+
332
+ <div class="axi-picker">
333
+ <button class="axi-picker__btn" aria-haspopup="listbox"
334
+ aria-expanded="false" aria-controls="months">Jul 2026</button>
335
+ <div class="axi-picker__pop" id="months" role="listbox" hidden>
336
+ <button class="axi-picker__opt" role="option" aria-selected="true">
337
+ Jul 2026
338
+ </button>
339
+ </div>
340
+ </div>
341
+
342
+ Like the menu and the tooltip, the language ships no script: `hidden`,
343
+ `aria-expanded` and `aria-selected` are the whole state, and the consumer
344
+ toggles them. gallery.js is the reference wiring, arrow keys and Escape
345
+ included. */
346
+ .axi-picker { position: relative; display: inline-flex; }
347
+ .axi-picker__btn {
348
+ display: inline-flex; align-items: center;
349
+ width: 100%;
350
+ text-align: left;
351
+ }
352
+ .axi-picker__pop {
353
+ /* Above .axi-mast's z-index: 40, for the same reason .axi-menu__pop is. */
354
+ position: absolute; top: calc(100% + 9px); left: 0; z-index: 41;
355
+ /* Never narrower than the box it came out of, and no wider than its
356
+ longest row needs. A list that shrinks to the text is a list that has
357
+ moved, and the eye has to find the column again. */
358
+ min-width: 100%;
359
+ max-height: 340px; overflow-y: auto;
360
+ padding: 6px;
361
+ background: var(--axi-surface-raised);
362
+ border: var(--axi-border-panel) solid var(--axi-ink-line);
363
+ border-radius: var(--axi-radius);
364
+ /* The block falls outside the popover's own box. An ancestor that clips -
365
+ a scrolling pane, a panel with overflow: hidden - eats it, and the only
366
+ 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);
368
+ }
369
+ .axi-picker__pop[hidden] { display: none; }
370
+ /* A popover cannot always live beside its trigger. Inside a pane that scrolls
371
+ or a panel that clips, `position: absolute` puts the list where the
372
+ overflow can eat it - and the offset block falls outside the popover's box,
373
+ so the block is the first thing to go. The way out is the tooltip's
374
+ contract: append the popover to <body> and set left/top from script, having
375
+ measured the trigger. The box is unchanged; only who positions it moves. */
376
+ .axi-picker__pop--fixed { position: fixed; }
377
+ .axi-picker__opt {
378
+ display: flex; align-items: center; gap: 9px;
379
+ width: 100%;
380
+ padding: 8px 9px;
381
+ border: 0;
382
+ border-radius: var(--axi-radius-sm);
383
+ background: transparent;
384
+ color: var(--axi-text-dim);
385
+ font: var(--axi-t-label);
386
+ font-size: 12.5px;
387
+ letter-spacing: var(--axi-ls-label);
388
+ text-transform: uppercase;
389
+ text-align: left;
390
+ cursor: pointer;
391
+ }
392
+ /* The tick is in every row, inked only in the chosen one. Give it to the
393
+ selected row alone and every label shifts by its width as the choice moves,
394
+ which turns picking an option into the list twitching. */
395
+ .axi-picker__opt::before {
396
+ content: "\2713";
397
+ color: transparent;
398
+ font-weight: 900;
399
+ }
400
+ .axi-picker__opt:hover, .axi-picker__opt:focus {
401
+ background: var(--axi-ground); color: var(--axi-text);
402
+ }
403
+ /* The page-wide focus ring sits 2px outside its element; inside a popover
404
+ this tight that is 2px into the neighbouring row, so pull it back in. */
405
+ .axi-picker__opt:focus-visible { outline-offset: -3px; }
406
+ .axi-picker__opt[aria-selected='true'] { color: var(--axi-text); }
407
+ .axi-picker__opt[aria-selected='true']::before { color: var(--axi-accent); }