@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.
- package/README.md +2 -0
- package/dist/axi.css +148 -4
- package/docs/RULES.md +17 -0
- package/docs/superpowers/plans/2026-09-23-axi-docs-site.md +2376 -0
- package/docs/superpowers/specs/2026-09-23-axi-docs-site-design.md +333 -0
- package/package.json +1 -1
- package/src/base.css +2 -0
- package/src/data.css +36 -0
- package/src/primitives.css +110 -4
|
@@ -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
package/src/base.css
CHANGED
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
|
package/src/primitives.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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); }
|