@axiapps/axi-design 1.7.1 → 1.9.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 +58 -0
- package/docs/RULES.md +15 -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 +14 -5
- package/src/data.css +36 -0
- package/src/shells.css +22 -0
|
@@ -0,0 +1,2376 @@
|
|
|
1
|
+
# axi-design Documentation Site Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Replace the single-page pattern gallery with a Bootstrap-style documentation site — per-component pages, narrative guides, search — generated from a component manifest and built entirely out of axi's own components.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A manifest (`docs/manifest/*.mjs`) declares every component family: its classes, summary, knobs, the RULES.md clauses it answers, and its examples as raw HTML strings. A generator (`scripts/site.mjs`) renders each example twice — raw into a live demo, escaped-and-highlighted into the code block beside it — so copyable markup cannot drift from what it produced. Narrative pages are Markdown rendered through `marked` into `.axi-prose`. Tests make an undocumented class a build failure.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 22 ESM, vitest, `marked` (new devDependency, build-time only). No runtime dependencies, no framework, no change to anything a consumer downloads.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-23-axi-docs-site-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- **Nothing in `src/` changes.** No new components, no edits to existing CSS. Gaps this work exposes are recorded for round two, not filled.
|
|
16
|
+
- **No runtime dependency, ever.** The only new dependency is `marked`, in `devDependencies` only. It must not appear in `dependencies`, in `dist/`, or in the npm `files` list.
|
|
17
|
+
- **`scripts/build.mjs` is touched in exactly one task** (Task 4, the generated README knob table). Every other task leaves it alone. `dist/axi.css` and `dist/accents.css` are byte-identical before and after this project.
|
|
18
|
+
- **Every published `/vN/axi.css` keeps resolving.** Those are staged from git tags in `pages.yml`; the per-tag loop is preserved verbatim.
|
|
19
|
+
- **All docs-only CSS is prefixed `.docs-`** and lives in `docs/site/docs.css`. It is never read by `build.mjs` and never enters `dist/`.
|
|
20
|
+
- **Base path:** the site is served from `/axi-design/` on Pages and `/` locally. Every emitted link goes through the single `url()` helper from `docs/site/shell.mjs`. A raw `href="/` or `src="/` in emitted output is a test failure.
|
|
21
|
+
- **`layer` is declared per manifest entry**, never inferred from which `src/` file the CSS sits in. Known layers: `primitives`, `layout`, `shells`, `data`, `prose`, `utilities`.
|
|
22
|
+
- **Test parallelism** is already capped at 2 forks in `vitest.config.mjs`. Run `npm test`; do not add a `--maxWorkers` flag.
|
|
23
|
+
- **Commit style:** lowercase `type: subject`, an em-dash clause where it helps, body explaining *why*. End every commit message with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
|
24
|
+
|
|
25
|
+
## Review Focus
|
|
26
|
+
|
|
27
|
+
Five failure modes the spec implies but that no task's happy-path tests would otherwise exercise. Each line's test is assigned to the task that owns the code.
|
|
28
|
+
|
|
29
|
+
1. **Example markup containing `<`, `&`, or a literal `</pre>`** — must appear verbatim in the code block and must not terminate it or inject markup. *Test added to Task 5.*
|
|
30
|
+
2. **A component `id` colliding with a static route** (`index`, `search`, `start`, `rules`, `theming`, `components`, `gallery`, `llms`) — two pages would write to one path and the survivor is whichever ran last. Must fail the build instead. *Test added to Task 2.*
|
|
31
|
+
3. **A manifest `rules: [n]` pointing at a clause number RULES.md does not have** — after a rule is renumbered or removed, every "Rules this answers" link on affected pages goes dead silently. *Test added to Task 2.*
|
|
32
|
+
4. **An `<!-- axi:knobs -->` placeholder inside a fenced code block in Markdown** — a guide showing the placeholder as an example would have it substituted instead of displayed; and an unrecognised `axi:` name must be a build error, not a silent passthrough. *Test added to Task 8.*
|
|
33
|
+
5. **A persisted accent id in `localStorage` that is no longer in `accents.json`** — a returning visitor gets an unset or invalid `--axi-accent` after the list changes. Must fall back to the first official accent. *Test added to Task 9.*
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Task 1: CSS and RULES introspection
|
|
38
|
+
|
|
39
|
+
The enforcement tests need to know, mechanically, which classes `src/` defines, which custom properties it reads with a fallback, and which rule numbers `docs/RULES.md` declares. Every later enforcement check consumes this module.
|
|
40
|
+
|
|
41
|
+
**Files:**
|
|
42
|
+
- Create: `docs/manifest/introspect.mjs`
|
|
43
|
+
- Test: `tests/introspect.test.mjs`
|
|
44
|
+
|
|
45
|
+
**Interfaces:**
|
|
46
|
+
- Consumes: nothing.
|
|
47
|
+
- Produces:
|
|
48
|
+
- `sources(): string` — every `src/*.css` file concatenated.
|
|
49
|
+
- `definedClasses(css?: string): string[]` — sorted, each entry leading-dot form, e.g. `'.axi-btn'`.
|
|
50
|
+
- `fallbackKnobs(css?: string): string[]` — sorted custom property names read as `var(--axi-x, …)`, e.g. `'--axi-meter-v'`.
|
|
51
|
+
- `ruleNumbers(): number[]` — the `n` of every `## n. …` heading in `docs/RULES.md`.
|
|
52
|
+
|
|
53
|
+
- [ ] **Step 1: Write the failing test**
|
|
54
|
+
|
|
55
|
+
Create `tests/introspect.test.mjs`:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { describe, it, expect } from 'vitest'
|
|
59
|
+
import { definedClasses, fallbackKnobs, ruleNumbers } from '../docs/manifest/introspect.mjs'
|
|
60
|
+
|
|
61
|
+
describe('definedClasses', () => {
|
|
62
|
+
it('takes classes from selector text', () => {
|
|
63
|
+
const css = '.axi-btn, .axi-btn--ghost { color: red; }'
|
|
64
|
+
expect(definedClasses(css)).toEqual(['.axi-btn', '.axi-btn--ghost'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('finds classes nested inside an at-rule', () => {
|
|
68
|
+
const css = '@media (min-width: 700px) { .axi-grid { display: grid; } }'
|
|
69
|
+
expect(definedClasses(css)).toEqual(['.axi-grid'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ignores a class named inside a declaration value', () => {
|
|
73
|
+
const css = '.axi-card { background: url("sprite-axi-card.png"); }'
|
|
74
|
+
expect(definedClasses(css)).toEqual(['.axi-card'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('ignores a class mentioned only in a comment', () => {
|
|
78
|
+
const css = '/* .axi-ghost was removed in 1.6 */ .axi-btn { color: red; }'
|
|
79
|
+
expect(definedClasses(css)).toEqual(['.axi-btn'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('reads the real stylesheet and finds a known component', () => {
|
|
83
|
+
expect(definedClasses()).toContain('.axi-meter__fill')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('fallbackKnobs', () => {
|
|
88
|
+
it('takes only properties read with a fallback', () => {
|
|
89
|
+
const css = '.axi-meter { height: var(--axi-meter-h, 12px); color: var(--axi-text); }'
|
|
90
|
+
expect(fallbackKnobs(css)).toEqual(['--axi-meter-h'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('tolerates whitespace inside the var call', () => {
|
|
94
|
+
expect(fallbackKnobs('a { width: var( --axi-switch-w , 46px ); }')).toEqual(['--axi-switch-w'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('reads the real stylesheet and finds a known knob', () => {
|
|
98
|
+
expect(fallbackKnobs()).toContain('--axi-meter-v')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('ruleNumbers', () => {
|
|
103
|
+
it('returns the numbered clauses of RULES.md, in order', () => {
|
|
104
|
+
expect(ruleNumbers()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
110
|
+
|
|
111
|
+
Run: `npx vitest run tests/introspect.test.mjs`
|
|
112
|
+
Expected: FAIL — `Failed to load ../docs/manifest/introspect.mjs`.
|
|
113
|
+
|
|
114
|
+
- [ ] **Step 3: Write the implementation**
|
|
115
|
+
|
|
116
|
+
Create `docs/manifest/introspect.mjs`:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
120
|
+
import { resolve, dirname } from 'node:path'
|
|
121
|
+
import { fileURLToPath } from 'node:url'
|
|
122
|
+
|
|
123
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
124
|
+
|
|
125
|
+
export function sources() {
|
|
126
|
+
return readdirSync(resolve(ROOT, 'src'))
|
|
127
|
+
.filter((name) => name.endsWith('.css') && !name.startsWith('.'))
|
|
128
|
+
.sort()
|
|
129
|
+
.map((name) => readFileSync(resolve(ROOT, 'src', name), 'utf8'))
|
|
130
|
+
.join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Every class the stylesheet DEFINES, which means every class appearing in
|
|
134
|
+
// selector text. Splitting on braces alternates between selector text and
|
|
135
|
+
// declaration lists; a declaration list is told apart by containing a
|
|
136
|
+
// semicolon, which selector text never does. That leaves one theoretical
|
|
137
|
+
// false positive - a single unterminated declaration whose value contains the
|
|
138
|
+
// literal text `.axi-` - and the failure mode is a loud one (the coverage
|
|
139
|
+
// test demands a page for a class that does not exist), not a silent miss.
|
|
140
|
+
export function definedClasses(css = sources()) {
|
|
141
|
+
const found = new Set()
|
|
142
|
+
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
143
|
+
for (const chunk of stripped.split(/[{}]/)) {
|
|
144
|
+
if (chunk.includes(';')) continue
|
|
145
|
+
for (const match of chunk.matchAll(/\.(axi-[a-z0-9_-]+)/g)) found.add(`.${match[1]}`)
|
|
146
|
+
}
|
|
147
|
+
return [...found].sort()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A per-instance knob is, precisely, a custom property the components read
|
|
151
|
+
// with a fallback: the fallback is what lets a consumer leave it unset, and
|
|
152
|
+
// leaving it unset is what makes it a knob rather than a theme token.
|
|
153
|
+
export function fallbackKnobs(css = sources()) {
|
|
154
|
+
const found = new Set()
|
|
155
|
+
for (const match of css.matchAll(/var\(\s*(--axi-[a-z0-9-]+)\s*,/g)) found.add(match[1])
|
|
156
|
+
return [...found].sort()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ruleNumbers() {
|
|
160
|
+
const md = readFileSync(resolve(ROOT, 'docs/RULES.md'), 'utf8')
|
|
161
|
+
return [...md.matchAll(/^## (\d+)\. /gm)].map((match) => Number(match[1]))
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
166
|
+
|
|
167
|
+
Run: `npx vitest run tests/introspect.test.mjs`
|
|
168
|
+
Expected: PASS, 9 tests.
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 5: Commit**
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git add docs/manifest/introspect.mjs tests/introspect.test.mjs
|
|
174
|
+
git commit -m "$(cat <<'EOF'
|
|
175
|
+
feat(docs): read the stylesheet mechanically — classes, knobs, rule numbers
|
|
176
|
+
|
|
177
|
+
Every enforcement check the documentation site is about to grow needs to know
|
|
178
|
+
what src/ actually defines, and asking a human to keep a second list in sync is
|
|
179
|
+
the thing being designed out. One module, three functions, consumed by all of
|
|
180
|
+
them.
|
|
181
|
+
|
|
182
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
183
|
+
EOF
|
|
184
|
+
)"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### Task 2: Manifest loader, entry validation, and the data layer
|
|
190
|
+
|
|
191
|
+
Establishes the manifest format and validates it, seeded with the `data` layer. The global class-coverage check comes in Task 3, once every layer exists — turning it on here would fail for the five layers not yet written, which is noise rather than signal.
|
|
192
|
+
|
|
193
|
+
**Files:**
|
|
194
|
+
- Create: `docs/manifest/index.mjs`, `docs/manifest/data.mjs`
|
|
195
|
+
- Test: `tests/manifest.test.mjs`
|
|
196
|
+
|
|
197
|
+
**Interfaces:**
|
|
198
|
+
- Consumes: `definedClasses`, `ruleNumbers` from `docs/manifest/introspect.mjs`.
|
|
199
|
+
- Produces:
|
|
200
|
+
- `LAYERS: string[]` — `['primitives', 'layout', 'shells', 'data', 'prose', 'utilities']`, in sidebar order.
|
|
201
|
+
- `RESERVED_IDS: string[]` — static route segments a component id may not take.
|
|
202
|
+
- `entries(): Entry[]` — every manifest entry, flattened, in layer order.
|
|
203
|
+
- `Entry` — `{ id, name, layer, classes: string[], summary, rules: number[], knobs: string[], notes?: string, examples: { title, note?, html }[] }`.
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 1: Write the failing test**
|
|
206
|
+
|
|
207
|
+
Create `tests/manifest.test.mjs`:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { describe, it, expect } from 'vitest'
|
|
211
|
+
import { entries, LAYERS, RESERVED_IDS } from '../docs/manifest/index.mjs'
|
|
212
|
+
import { definedClasses, ruleNumbers } from '../docs/manifest/introspect.mjs'
|
|
213
|
+
|
|
214
|
+
const ALL = entries()
|
|
215
|
+
|
|
216
|
+
describe('manifest entry shape', () => {
|
|
217
|
+
it('gives every entry a unique id', () => {
|
|
218
|
+
const ids = ALL.map((e) => e.id)
|
|
219
|
+
expect(ids).toEqual([...new Set(ids)])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('uses only kebab-case ids', () => {
|
|
223
|
+
for (const e of ALL) expect(e.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// A component id becomes a directory under /components/, but the generator
|
|
227
|
+
// also writes static routes at the site root. An id that collides with one
|
|
228
|
+
// of those means two pages racing for one path, and the survivor is whichever
|
|
229
|
+
// happened to be written last - a silent, ordering-dependent loss.
|
|
230
|
+
it('never takes an id reserved by a static route', () => {
|
|
231
|
+
for (const e of ALL) expect(RESERVED_IDS).not.toContain(e.id)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('declares a known layer', () => {
|
|
235
|
+
for (const e of ALL) expect(LAYERS).toContain(e.layer)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('has a non-empty name and summary', () => {
|
|
239
|
+
for (const e of ALL) {
|
|
240
|
+
expect(e.name.length).toBeGreaterThan(0)
|
|
241
|
+
expect(e.summary.length).toBeGreaterThan(0)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('lists at least one class and one example', () => {
|
|
246
|
+
for (const e of ALL) {
|
|
247
|
+
expect(e.classes.length).toBeGreaterThan(0)
|
|
248
|
+
expect(e.examples.length).toBeGreaterThan(0)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('gives every example a title and markup', () => {
|
|
253
|
+
for (const e of ALL) {
|
|
254
|
+
for (const ex of e.examples) {
|
|
255
|
+
expect(ex.title.length).toBeGreaterThan(0)
|
|
256
|
+
expect(ex.html.trim().length).toBeGreaterThan(0)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('manifest against the stylesheet', () => {
|
|
263
|
+
it('claims only classes src/ actually defines', () => {
|
|
264
|
+
const defined = new Set(definedClasses())
|
|
265
|
+
for (const e of ALL) {
|
|
266
|
+
for (const cls of e.classes) {
|
|
267
|
+
expect(defined.has(cls), `${e.id} claims ${cls}, which src/ does not define`).toBe(true)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('never claims one class from two entries', () => {
|
|
273
|
+
const owner = new Map()
|
|
274
|
+
for (const e of ALL) {
|
|
275
|
+
for (const cls of e.classes) {
|
|
276
|
+
expect(owner.has(cls), `${cls} is claimed by both ${owner.get(cls)} and ${e.id}`).toBe(false)
|
|
277
|
+
owner.set(cls, e.id)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('uses only classes src/ defines inside example markup', () => {
|
|
283
|
+
const defined = new Set(definedClasses())
|
|
284
|
+
for (const e of ALL) {
|
|
285
|
+
for (const ex of e.examples) {
|
|
286
|
+
for (const match of ex.html.matchAll(/class="([^"]*)"/g)) {
|
|
287
|
+
for (const cls of match[1].split(/\s+/).filter(Boolean)) {
|
|
288
|
+
if (!cls.startsWith('axi-')) continue
|
|
289
|
+
expect(defined.has(`.${cls}`), `${e.id}/"${ex.title}" uses .${cls}, undefined in src/`).toBe(true)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// "Rules this answers" deep-links to /rules/#rule-<n>. A rule renumbered or
|
|
297
|
+
// removed in RULES.md leaves those links pointing at nothing, on every page
|
|
298
|
+
// that cited it, with no other symptom.
|
|
299
|
+
it('cites only rule numbers RULES.md declares', () => {
|
|
300
|
+
const known = new Set(ruleNumbers())
|
|
301
|
+
for (const e of ALL) {
|
|
302
|
+
for (const n of e.rules) {
|
|
303
|
+
expect(known.has(n), `${e.id} cites rule ${n}, which RULES.md does not declare`).toBe(true)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
311
|
+
|
|
312
|
+
Run: `npx vitest run tests/manifest.test.mjs`
|
|
313
|
+
Expected: FAIL — `Failed to load ../docs/manifest/index.mjs`.
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 3: Write the loader**
|
|
316
|
+
|
|
317
|
+
Create `docs/manifest/index.mjs`:
|
|
318
|
+
|
|
319
|
+
```js
|
|
320
|
+
import data from './data.mjs'
|
|
321
|
+
|
|
322
|
+
// Sidebar order. Not the order of src/ - that file split is by cascade
|
|
323
|
+
// order, not by category, and the two genuinely differ (.axi-panel is defined
|
|
324
|
+
// in primitives.css and documented under Layout).
|
|
325
|
+
export const LAYERS = ['primitives', 'layout', 'shells', 'data', 'prose', 'utilities']
|
|
326
|
+
|
|
327
|
+
// Static routes the generator writes. A component id may not take one.
|
|
328
|
+
export const RESERVED_IDS = [
|
|
329
|
+
'index', 'start', 'rules', 'theming', 'components', 'gallery', 'search', 'llms',
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
const BY_LAYER = { primitives: [], layout: [], shells: [], data, prose: [], utilities: [] }
|
|
333
|
+
|
|
334
|
+
export function entries() {
|
|
335
|
+
return LAYERS.flatMap((layer) => BY_LAYER[layer] ?? [])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function byLayer() {
|
|
339
|
+
return LAYERS.map((layer) => ({ layer, items: BY_LAYER[layer] ?? [] })).filter((g) => g.items.length)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function findEntry(id) {
|
|
343
|
+
return entries().find((e) => e.id === id)
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- [ ] **Step 4: Write the data layer manifest**
|
|
348
|
+
|
|
349
|
+
Create `docs/manifest/data.mjs`. Port the markup from the `#data` section of `index.html` (lines 241–351) rather than inventing new examples — it is already written and already correct. Seven entries, in this order: `stat`, `table`, `meter`, `bars`, `plot`, `axis`, `legend`. Between them they must claim every class `definedClasses()` reports from `src/data.css`:
|
|
350
|
+
|
|
351
|
+
`.axi-stat .axi-stat__k .axi-stat__n .axi-stat--accent .axi-stat--meta .axi-stat--ok .axi-stat--warn .axi-stat--danger .axi-table .axi-table__num .axi-table__rank .axi-table__rank--top .axi-table__who .axi-meter .axi-meter__fill .axi-meter-list .axi-meter-list__name .axi-meter-list__value .axi-bars .axi-bars__col .axi-bars__part .axi-plot .axi-plot__svg .axi-plot__area .axi-plot__line .axi-axis .axi-legend .axi-legend__key`
|
|
352
|
+
|
|
353
|
+
The `meter` entry in full, as the pattern for the other six:
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
export default [
|
|
357
|
+
// …stat, table…
|
|
358
|
+
{
|
|
359
|
+
id: 'meter',
|
|
360
|
+
name: 'Meter',
|
|
361
|
+
layer: 'data',
|
|
362
|
+
classes: ['.axi-meter', '.axi-meter__fill', '.axi-meter-list', '.axi-meter-list__name', '.axi-meter-list__value'],
|
|
363
|
+
summary: 'A horizontal bar showing one value against its full extent. Drawn in the series ink, filled to a hard edge.',
|
|
364
|
+
rules: [1, 2, 9],
|
|
365
|
+
knobs: ['--axi-meter-v', '--axi-meter-h', '--axi-series', '--axi-meter-label', '--axi-meter-value'],
|
|
366
|
+
notes: `A meter says *how much*, and a quantity in this language is drawn as
|
|
367
|
+
length. Reach for \`--axi-series\` to change which ink a bar is drawn in; never
|
|
368
|
+
fade the accent to make a bar quieter.`,
|
|
369
|
+
examples: [
|
|
370
|
+
{
|
|
371
|
+
title: 'A single meter',
|
|
372
|
+
note: 'Fullness comes from --axi-meter-v',
|
|
373
|
+
html: `<div class="axi-meter"><span class="axi-meter__fill" style="--axi-meter-v: 62%"></span></div>`,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
title: 'A ranked list',
|
|
377
|
+
note: 'Leader in accent, the rest in a faint neutral',
|
|
378
|
+
html: `<div class="axi-meter-list">
|
|
379
|
+
<span class="axi-meter-list__name">Scourge</span>
|
|
380
|
+
<div class="axi-meter"><span class="axi-meter__fill" style="--axi-meter-v: 100%"></span></div>
|
|
381
|
+
<span class="axi-meter-list__value">1.4M</span>
|
|
382
|
+
<span class="axi-meter-list__name">Spellbreaker</span>
|
|
383
|
+
<div class="axi-meter"><span class="axi-meter__fill" style="--axi-meter-v: 63%; --axi-series: var(--axi-text-faint)"></span></div>
|
|
384
|
+
<span class="axi-meter-list__value">884k</span>
|
|
385
|
+
</div>`,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
// …bars, plot, axis, legend…
|
|
390
|
+
]
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Rule citations for the other six, from `docs/RULES.md`: `stat` → `[5, 3]`; `table` → `[8, 3]`; `bars` → `[9, 10]`; `plot` → `[1, 10]`; `axis` → `[2]`; `legend` → `[5, 7]`.
|
|
394
|
+
|
|
395
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
396
|
+
|
|
397
|
+
Run: `npx vitest run tests/manifest.test.mjs`
|
|
398
|
+
Expected: PASS. If "claims only classes src/ actually defines" fails, a class name in an entry is a typo. If "never claims one class from two entries" fails, two entries both list a shared class — decide which family owns it.
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 6: Verify the data layer is complete**
|
|
401
|
+
|
|
402
|
+
Run:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
node -e "
|
|
406
|
+
import('./docs/manifest/index.mjs').then(async (m) => {
|
|
407
|
+
const { definedClasses } = await import('./docs/manifest/introspect.mjs')
|
|
408
|
+
const { readFileSync } = await import('node:fs')
|
|
409
|
+
const inData = new Set(definedClasses(readFileSync('src/data.css', 'utf8')))
|
|
410
|
+
const claimed = new Set(m.entries().flatMap((e) => e.classes))
|
|
411
|
+
const missing = [...inData].filter((c) => !claimed.has(c))
|
|
412
|
+
console.log(missing.length ? 'UNDOCUMENTED: ' + missing.join(' ') : 'data layer complete')
|
|
413
|
+
})"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Expected: `data layer complete`. Anything listed needs an entry or needs adding to an existing entry's `classes`.
|
|
417
|
+
|
|
418
|
+
- [ ] **Step 7: Commit**
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
git add docs/manifest/index.mjs docs/manifest/data.mjs tests/manifest.test.mjs
|
|
422
|
+
git commit -m "$(cat <<'EOF'
|
|
423
|
+
feat(docs): the component manifest, validated, seeded with the data layer
|
|
424
|
+
|
|
425
|
+
One entry per component family: its classes, its knobs, the RULES clauses it
|
|
426
|
+
answers, and its examples as markup. The examples are the point - the generator
|
|
427
|
+
renders each string twice, once as the demo and once as the code beside it, so
|
|
428
|
+
the code cannot drift from what produced it.
|
|
429
|
+
|
|
430
|
+
Validation lands with the format rather than after it: unique ids, no id
|
|
431
|
+
colliding with a static route, no class claimed twice, no class that src/ does
|
|
432
|
+
not define, no citation of a rule RULES.md does not declare.
|
|
433
|
+
|
|
434
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
435
|
+
EOF
|
|
436
|
+
)"
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
### Task 3: The remaining five layers, and the coverage gate
|
|
442
|
+
|
|
443
|
+
Every layer written, then the check that makes an undocumented class a build failure. **This is the task where the gaps in the system surface.**
|
|
444
|
+
|
|
445
|
+
**Files:**
|
|
446
|
+
- Create: `docs/manifest/primitives.mjs`, `layout.mjs`, `shells.mjs`, `prose.mjs`, `utilities.mjs`
|
|
447
|
+
- Modify: `docs/manifest/index.mjs` (import and wire the five new layers into `BY_LAYER`)
|
|
448
|
+
- Modify: `tests/manifest.test.mjs` (add the coverage gate)
|
|
449
|
+
|
|
450
|
+
**Interfaces:**
|
|
451
|
+
- Consumes: the `Entry` shape from Task 2.
|
|
452
|
+
- Produces: `entries()` now covering all six layers.
|
|
453
|
+
|
|
454
|
+
- [ ] **Step 1: Write the failing coverage test**
|
|
455
|
+
|
|
456
|
+
Append to `tests/manifest.test.mjs`:
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
// The forcing function. An undocumented component fails the build, which is
|
|
460
|
+
// how a component added in round two cannot land without its page. There is
|
|
461
|
+
// deliberately no exclusion list: a class that genuinely belongs to no visual
|
|
462
|
+
// family gets a real entry under the `utilities` layer. An exclusion list is
|
|
463
|
+
// precisely the escape hatch the rest of this repo's tests are written to
|
|
464
|
+
// avoid, because it turns "undocumented" into a one-line, unreviewed opt-out.
|
|
465
|
+
describe('coverage', () => {
|
|
466
|
+
it('documents every class src/ defines', () => {
|
|
467
|
+
const claimed = new Set(ALL.flatMap((e) => e.classes))
|
|
468
|
+
const undocumented = definedClasses().filter((cls) => !claimed.has(cls))
|
|
469
|
+
expect(undocumented, `undocumented: ${undocumented.join(' ')}`).toEqual([])
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- [ ] **Step 2: Run test to see exactly which classes are undocumented**
|
|
475
|
+
|
|
476
|
+
Run: `npx vitest run tests/manifest.test.mjs -t coverage`
|
|
477
|
+
Expected: FAIL, listing roughly 64 classes — everything outside `src/data.css`. That list is the work of this task.
|
|
478
|
+
|
|
479
|
+
- [ ] **Step 3: Write the five layer manifests**
|
|
480
|
+
|
|
481
|
+
Same entry shape as Task 2. Port example markup from the matching sections of `index.html`. Grouping — note that `layer` deliberately diverges from the source file in five places:
|
|
482
|
+
|
|
483
|
+
```
|
|
484
|
+
primitives.mjs btn (.axi-btn --primary --ghost --dashed)
|
|
485
|
+
chip (.axi-chip --accent --meta --ok --warn --danger)
|
|
486
|
+
input (.axi-input) select (.axi-select)
|
|
487
|
+
switch (.axi-switch __knob) pill (.axi-pill)
|
|
488
|
+
search (.axi-search __icon)
|
|
489
|
+
notice (.axi-notice __icon) [defined in shells.css]
|
|
490
|
+
tooltip (.axi-tooltip) [defined in shells.css]
|
|
491
|
+
diamond (.axi-diamond --accent --ok --warn --danger --series)
|
|
492
|
+
badge-count (.axi-badge-count)
|
|
493
|
+
|
|
494
|
+
layout.mjs page (.axi-page --narrow --wide)
|
|
495
|
+
grid (.axi-grid) row (.axi-row) stack (.axi-stack)
|
|
496
|
+
panel (.axi-panel) [defined in primitives.css]
|
|
497
|
+
|
|
498
|
+
shells.mjs card (.axi-card __head __title __meta __glyph __go --strip)
|
|
499
|
+
window (.axi-window __body) titlebar (.axi-titlebar __btns)
|
|
500
|
+
drawer (.axi-drawer __head __body __close) scrim (.axi-scrim)
|
|
501
|
+
menu (.axi-menu __pop) toolbar (.axi-toolbar)
|
|
502
|
+
mast (.axi-mast __in) brand (.axi-brand __name)
|
|
503
|
+
sigil (.axi-sigil) tabs (.axi-tabs)
|
|
504
|
+
|
|
505
|
+
prose.mjs prose (.axi-prose)
|
|
506
|
+
quote (.axi-quote) [defined in shells.css]
|
|
507
|
+
eyebrow (.axi-eyebrow) [defined in shells.css]
|
|
508
|
+
|
|
509
|
+
utilities.mjs sr-only (.axi-sr-only) [defined in base.css]
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**Choosing each entry's `rules`.** Cite the clauses a reader would need in order to understand why the component looks as it does — normally two, never more than three, most specific first. The eleven clause titles, for reference:
|
|
513
|
+
|
|
514
|
+
| n | Clause |
|
|
515
|
+
|---|---|
|
|
516
|
+
| 1 | No gradients on surfaces |
|
|
517
|
+
| 2 | No colour at partial opacity over the ground |
|
|
518
|
+
| 3 | Every raised element is outlined and blocked |
|
|
519
|
+
| 4 | Hover lifts |
|
|
520
|
+
| 5 | Filled means status, outlined means annotation |
|
|
521
|
+
| 6 | One cool ink is reserved for meta |
|
|
522
|
+
| 7 | The diamond is the family motif |
|
|
523
|
+
| 8 | A table is the panel's interior |
|
|
524
|
+
| 9 | A quantity is drawn as length, never intensity |
|
|
525
|
+
| 10 | A chart's ink is the accent |
|
|
526
|
+
| 11 | An indicator of work animates a composited property |
|
|
527
|
+
|
|
528
|
+
So: `btn` → `[3, 4]`; `chip` → `[5, 2]`; `diamond` → `[7, 5]`; `panel` → `[3]`; `card` → `[3, 4]`; `drawer` → `[3]`; `prose` → `[3]`. An entry with no applicable clause — `sr-only`, `page`, `grid`, `row`, `stack` — takes `[]` rather than a stretched citation; an unconvincing rule reference is worse than none, because it teaches the reader that the section is decorative.
|
|
529
|
+
|
|
530
|
+
`sr-only` is the case the "no exclusion list" rule exists for. It gets a real entry:
|
|
531
|
+
|
|
532
|
+
```js
|
|
533
|
+
export default [
|
|
534
|
+
{
|
|
535
|
+
id: 'sr-only',
|
|
536
|
+
name: 'Screen-reader only',
|
|
537
|
+
layer: 'utilities',
|
|
538
|
+
classes: ['.axi-sr-only'],
|
|
539
|
+
summary: 'Hides an element visually while leaving it in the accessibility tree. Use it for a label whose meaning is already carried visually by an icon.',
|
|
540
|
+
rules: [],
|
|
541
|
+
knobs: [],
|
|
542
|
+
notes: `Not a visual component and not themed. It exists because several
|
|
543
|
+
components - the accent switcher's \`<label>\`, a close button's name - need a
|
|
544
|
+
name a screen reader can read and a sighted reader does not need to see.`,
|
|
545
|
+
examples: [
|
|
546
|
+
{
|
|
547
|
+
title: 'Naming an icon-only control',
|
|
548
|
+
html: `<label class="axi-sr-only" for="accent-demo">Accent colour</label>
|
|
549
|
+
<select class="axi-select" id="accent-demo"><option>Axi Gold</option></select>`,
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
]
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- [ ] **Step 4: Wire the layers into the loader**
|
|
557
|
+
|
|
558
|
+
Modify `docs/manifest/index.mjs` — replace the import and `BY_LAYER`:
|
|
559
|
+
|
|
560
|
+
```js
|
|
561
|
+
import primitives from './primitives.mjs'
|
|
562
|
+
import layout from './layout.mjs'
|
|
563
|
+
import shells from './shells.mjs'
|
|
564
|
+
import data from './data.mjs'
|
|
565
|
+
import prose from './prose.mjs'
|
|
566
|
+
import utilities from './utilities.mjs'
|
|
567
|
+
|
|
568
|
+
const BY_LAYER = { primitives, layout, shells, data, prose, utilities }
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 5: Run the full manifest suite**
|
|
572
|
+
|
|
573
|
+
Run: `npx vitest run tests/manifest.test.mjs`
|
|
574
|
+
Expected: PASS, including `documents every class src/ defines`.
|
|
575
|
+
|
|
576
|
+
- [ ] **Step 6: Record what the gate exposed**
|
|
577
|
+
|
|
578
|
+
The classes that were awkward to place are round two's input. Append a short section to the spec's risks — or, if there is nothing awkward, note that explicitly so round two does not re-litigate it. Create `docs/superpowers/specs/2026-09-23-round-two-notes.md` with one line per component that had no natural home, and one line per gap noticed while writing examples (a form has no checkbox, a dialog has no modal, and so on).
|
|
579
|
+
|
|
580
|
+
- [ ] **Step 7: Commit**
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
git add docs/manifest tests/manifest.test.mjs docs/superpowers/specs/2026-09-23-round-two-notes.md
|
|
584
|
+
git commit -m "$(cat <<'EOF'
|
|
585
|
+
feat(docs): manifest every layer, and make an undocumented class a failure
|
|
586
|
+
|
|
587
|
+
All six layers, and the gate that gives the manifest its teeth: a class defined
|
|
588
|
+
in src/ and claimed by no entry fails the build. No exclusion list - a class
|
|
589
|
+
belonging to no visual family gets a real entry under `utilities`, which is
|
|
590
|
+
what .axi-sr-only now has. An exclusion list would make "undocumented" a
|
|
591
|
+
one-line unreviewed opt-out, which is the thing being designed out.
|
|
592
|
+
|
|
593
|
+
Note that `layer` is declared, not inferred: .axi-panel is defined in
|
|
594
|
+
primitives.css and documented under Layout, .axi-quote is defined in shells.css
|
|
595
|
+
and documented under Prose. The src/ split is by cascade order, not category.
|
|
596
|
+
|
|
597
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
598
|
+
EOF
|
|
599
|
+
)"
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
### Task 4: The knob table, generated into the README
|
|
605
|
+
|
|
606
|
+
`knobs.mjs` becomes the source of truth for the per-instance knob surface, feeding both each component page and the README table that can currently drift from `src/` in silence. This is the only task that touches `scripts/build.mjs`.
|
|
607
|
+
|
|
608
|
+
**Files:**
|
|
609
|
+
- Create: `docs/manifest/knobs.mjs`
|
|
610
|
+
- Modify: `scripts/build.mjs` (add `buildKnobTable`, write it into README on direct run)
|
|
611
|
+
- Modify: `README.md` (wrap the existing table in markers)
|
|
612
|
+
- Modify: `tests/manifest.test.mjs` (knob coverage + README sync)
|
|
613
|
+
|
|
614
|
+
**Interfaces:**
|
|
615
|
+
- Consumes: `fallbackKnobs` from `introspect.mjs`; `entries()` from `index.mjs`.
|
|
616
|
+
- Produces:
|
|
617
|
+
- `KNOBS: Knob[]` where `Knob` is `{ name, sets, fallback, example }` — all four strings, `name` the `--axi-…` property.
|
|
618
|
+
- `knobsFor(names: string[]): Knob[]` — the subset, in `KNOBS` order.
|
|
619
|
+
- From `build.mjs`: `buildKnobTable(knobs?): string` — the Markdown table, no surrounding markers.
|
|
620
|
+
|
|
621
|
+
- [ ] **Step 1: Write the failing tests**
|
|
622
|
+
|
|
623
|
+
Append to `tests/manifest.test.mjs`:
|
|
624
|
+
|
|
625
|
+
```js
|
|
626
|
+
import { KNOBS, knobsFor } from '../docs/manifest/knobs.mjs'
|
|
627
|
+
import { fallbackKnobs } from '../docs/manifest/introspect.mjs'
|
|
628
|
+
import { buildKnobTable } from '../scripts/build.mjs'
|
|
629
|
+
import { readFileSync } from 'node:fs'
|
|
630
|
+
|
|
631
|
+
describe('knobs', () => {
|
|
632
|
+
it('documents every custom property src/ reads with a fallback', () => {
|
|
633
|
+
const named = new Set(KNOBS.map((k) => k.name))
|
|
634
|
+
const undocumented = fallbackKnobs().filter((n) => !named.has(n))
|
|
635
|
+
expect(undocumented, `undocumented knobs: ${undocumented.join(' ')}`).toEqual([])
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('documents no knob src/ never reads', () => {
|
|
639
|
+
const read = new Set(fallbackKnobs())
|
|
640
|
+
const phantom = KNOBS.map((k) => k.name).filter((n) => !read.has(n))
|
|
641
|
+
expect(phantom, `documented but never read: ${phantom.join(' ')}`).toEqual([])
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('gives every knob a description, a fallback and an example', () => {
|
|
645
|
+
for (const k of KNOBS) {
|
|
646
|
+
expect(k.sets.length).toBeGreaterThan(0)
|
|
647
|
+
expect(k.fallback.length).toBeGreaterThan(0)
|
|
648
|
+
expect(k.example).toContain(k.name)
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('only lets an entry cite a knob that exists', () => {
|
|
653
|
+
const named = new Set(KNOBS.map((k) => k.name))
|
|
654
|
+
for (const e of ALL) {
|
|
655
|
+
for (const n of e.knobs) expect(named.has(n), `${e.id} cites unknown knob ${n}`).toBe(true)
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('returns the requested subset in canonical order', () => {
|
|
660
|
+
const picked = knobsFor([KNOBS[2].name, KNOBS[0].name])
|
|
661
|
+
expect(picked.map((k) => k.name)).toEqual([KNOBS[0].name, KNOBS[2].name])
|
|
662
|
+
})
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
// The README table was hand-maintained and could drift from src/ with no
|
|
666
|
+
// symptom. It is generated now, and this is what makes "generated" true.
|
|
667
|
+
describe('README knob table', () => {
|
|
668
|
+
it('matches the table generated from knobs.mjs', () => {
|
|
669
|
+
const readme = readFileSync('README.md', 'utf8')
|
|
670
|
+
const section = readme.match(/<!-- axi:knobs -->\n([\s\S]*?)\n<!-- \/axi:knobs -->/)
|
|
671
|
+
expect(section, 'README is missing the axi:knobs markers').not.toBeNull()
|
|
672
|
+
expect(section[1]).toBe(buildKnobTable())
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
678
|
+
|
|
679
|
+
Run: `npx vitest run tests/manifest.test.mjs`
|
|
680
|
+
Expected: FAIL — cannot resolve `docs/manifest/knobs.mjs`.
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 3: Write knobs.mjs**
|
|
683
|
+
|
|
684
|
+
Create `docs/manifest/knobs.mjs`. **One entry per knob — 23 of them.** The README's table has only 20 rows because several document a related group in one line (`--axi-switch-w` / `--axi-switch-h` / `--axi-switch-knob` share a row, as do `--axi-meter-label` / `--axi-meter-value`). A component page needs to show knobs individually, so the manifest splits them and the generated table will have 23 rows where the hand-written one had 20. Port the wording of each row verbatim otherwise — the README is accurate today, and this task removes the *possibility* of drift rather than rewriting the content.
|
|
685
|
+
|
|
686
|
+
The 23, from `fallbackKnobs()`: `--axi-bar-part`, `--axi-bar-v`, `--axi-bars-gap`, `--axi-card-strip`, `--axi-drawer-width`, `--axi-grid-min`, `--axi-menu-width`, `--axi-meter-h`, `--axi-meter-label`, `--axi-meter-v`, `--axi-meter-value`, `--axi-page-pad`, `--axi-panel-pad`, `--axi-pill-fill`, `--axi-plot-h`, `--axi-plot-rows`, `--axi-row-gap`, `--axi-series`, `--axi-stack-gap`, `--axi-switch-fill`, `--axi-switch-h`, `--axi-switch-knob`, `--axi-switch-w`. Shape:
|
|
687
|
+
|
|
688
|
+
```js
|
|
689
|
+
// The per-instance knob surface: custom properties the components read with a
|
|
690
|
+
// fallback, so a consumer can set one on a single element or any ancestor.
|
|
691
|
+
// Not theme tokens - setting most of these in :root is legal and meaningless,
|
|
692
|
+
// because they answer "how wide is *this* grid", not "what does the system
|
|
693
|
+
// look like". This file is the source of truth: the README table is generated
|
|
694
|
+
// from it, and each component page shows the subset its entry cites.
|
|
695
|
+
export const KNOBS = [
|
|
696
|
+
{
|
|
697
|
+
name: '--axi-meter-v',
|
|
698
|
+
sets: 'how full one `.axi-meter__fill` is',
|
|
699
|
+
fallback: '`0%`',
|
|
700
|
+
example: '<span class="axi-meter__fill" style="--axi-meter-v: 62%">',
|
|
701
|
+
},
|
|
702
|
+
// …17 more, in the README's existing order…
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
export function knobsFor(names) {
|
|
706
|
+
const wanted = new Set(names)
|
|
707
|
+
return KNOBS.filter((k) => wanted.has(k.name))
|
|
708
|
+
}
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
- [ ] **Step 4: Add the generator to build.mjs**
|
|
712
|
+
|
|
713
|
+
Modify `scripts/build.mjs`. Add after `buildAccentsCss`:
|
|
714
|
+
|
|
715
|
+
```js
|
|
716
|
+
// The knob table is generated into README.md between markers. It is the one
|
|
717
|
+
// table in this repo describing src/ that a human used to maintain by hand,
|
|
718
|
+
// and the one that could therefore go stale with no symptom at all - nothing
|
|
719
|
+
// imports a README.
|
|
720
|
+
import { KNOBS } from '../docs/manifest/knobs.mjs'
|
|
721
|
+
|
|
722
|
+
export function buildKnobTable(knobs = KNOBS) {
|
|
723
|
+
const rows = knobs.map((k) => `| \`${k.name}\` | ${k.sets} | ${k.fallback} | \`${k.example}\` |`)
|
|
724
|
+
return ['| Knob | Sets | Fallback | Example |', '|---|---|---|---|', ...rows].join('\n')
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function writeKnobTable(readme) {
|
|
728
|
+
return readme.replace(
|
|
729
|
+
/(<!-- axi:knobs -->\n)[\s\S]*?(\n<!-- \/axi:knobs -->)/,
|
|
730
|
+
(_, open, close) => `${open}${buildKnobTable()}${close}`,
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
And inside the direct-run block, after the accents write:
|
|
736
|
+
|
|
737
|
+
```js
|
|
738
|
+
const readmePath = resolve(ROOT, 'README.md')
|
|
739
|
+
writeFileSync(readmePath, writeKnobTable(readFileSync(readmePath, 'utf8')))
|
|
740
|
+
console.log(`wrote the knob table into README.md (${KNOBS.length} knobs)`)
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
- [ ] **Step 5: Add the markers to README.md**
|
|
744
|
+
|
|
745
|
+
In `README.md`, wrap the existing knob table — the `| Knob | Sets | …` header through its last row — like this, leaving the surrounding prose alone:
|
|
746
|
+
|
|
747
|
+
```markdown
|
|
748
|
+
<!-- axi:knobs -->
|
|
749
|
+
| Knob | Sets | Fallback | Example |
|
|
750
|
+
|---|---|---|---|
|
|
751
|
+
...
|
|
752
|
+
<!-- /axi:knobs -->
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
- [ ] **Step 6: Regenerate and review the diff by eye**
|
|
756
|
+
|
|
757
|
+
Run: `npm run build && git diff README.md`
|
|
758
|
+
|
|
759
|
+
Expected: the three grouped rows split into individual ones — 20 rows become 23 — and **nothing else changes**. Any other difference in wording, fallback or example means a row in `knobs.mjs` disagrees with what the README already said; the README is the accurate one today, so reconcile toward it before continuing.
|
|
760
|
+
|
|
761
|
+
- [ ] **Step 7: Run the full suite**
|
|
762
|
+
|
|
763
|
+
Run: `npm test`
|
|
764
|
+
Expected: PASS. `tests/build.test.mjs` and `tests/accents.test.mjs` must still pass untouched — `dist/` is byte-identical.
|
|
765
|
+
|
|
766
|
+
- [ ] **Step 8: Commit**
|
|
767
|
+
|
|
768
|
+
```bash
|
|
769
|
+
git add docs/manifest/knobs.mjs scripts/build.mjs README.md tests/manifest.test.mjs
|
|
770
|
+
git commit -m "$(cat <<'EOF'
|
|
771
|
+
feat(docs): the knob table becomes data, and the README's copy becomes generated
|
|
772
|
+
|
|
773
|
+
Eighteen per-instance knobs, maintained in one place and read from two: each
|
|
774
|
+
component page shows the subset its entry cites, and README.md's table is
|
|
775
|
+
written between markers by the build.
|
|
776
|
+
|
|
777
|
+
That table described src/ and was maintained by hand, which made it the one
|
|
778
|
+
piece of documentation in this repo that could go stale in complete silence -
|
|
779
|
+
nothing imports a README, so nothing would ever have noticed. Now a knob read
|
|
780
|
+
with a fallback in src/ and absent from knobs.mjs fails, a knob documented and
|
|
781
|
+
never read fails, and a README out of sync with the manifest fails.
|
|
782
|
+
|
|
783
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
784
|
+
EOF
|
|
785
|
+
)"
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
### Task 5: Escaping and build-time syntax highlighting
|
|
791
|
+
|
|
792
|
+
Turns an example's markup string into the highlighted code block shown beside its demo. No dependency: HTML is the only language in the samples.
|
|
793
|
+
|
|
794
|
+
**Files:**
|
|
795
|
+
- Create: `docs/site/highlight.mjs`
|
|
796
|
+
- Test: `tests/highlight.test.mjs`
|
|
797
|
+
|
|
798
|
+
**Interfaces:**
|
|
799
|
+
- Consumes: nothing.
|
|
800
|
+
- Produces:
|
|
801
|
+
- `escapeHtml(s: string): string`
|
|
802
|
+
- `highlight(source: string): string` — escaped HTML with `<span class="t-tag|t-attr|t-str|t-punc">` wrappers.
|
|
803
|
+
|
|
804
|
+
- [ ] **Step 1: Write the failing test**
|
|
805
|
+
|
|
806
|
+
Create `tests/highlight.test.mjs`:
|
|
807
|
+
|
|
808
|
+
```js
|
|
809
|
+
import { describe, it, expect } from 'vitest'
|
|
810
|
+
import { escapeHtml, highlight } from '../docs/site/highlight.mjs'
|
|
811
|
+
import { entries } from '../docs/manifest/index.mjs'
|
|
812
|
+
|
|
813
|
+
const unhighlight = (html) =>
|
|
814
|
+
html.replace(/<\/?span[^>]*>/g, '')
|
|
815
|
+
.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
816
|
+
|
|
817
|
+
describe('escapeHtml', () => {
|
|
818
|
+
it('escapes the four characters that can break out of a code block', () => {
|
|
819
|
+
expect(escapeHtml('<a href="x">&</a>')).toBe('<a href="x">&</a>')
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
describe('highlight', () => {
|
|
824
|
+
it('marks tag names, attribute names, values and punctuation', () => {
|
|
825
|
+
const out = highlight('<div class="axi-meter"></div>')
|
|
826
|
+
expect(out).toContain('<span class="t-tag">div</span>')
|
|
827
|
+
expect(out).toContain('<span class="t-attr">class</span>')
|
|
828
|
+
expect(out).toContain('<span class="t-str">"axi-meter"</span>')
|
|
829
|
+
expect(out).toContain('<span class="t-punc"><</span>')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it('handles a valueless attribute', () => {
|
|
833
|
+
expect(unhighlight(highlight('<div hidden></div>'))).toBe('<div hidden></div>')
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('handles a self-closing tag', () => {
|
|
837
|
+
expect(unhighlight(highlight('<input class="axi-input" />'))).toBe('<input class="axi-input" />')
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
// The guarantee that makes the code block trustworthy: what is displayed is
|
|
841
|
+
// exactly what the demo was rendered from, character for character.
|
|
842
|
+
it('round-trips every example in the manifest', () => {
|
|
843
|
+
for (const e of entries()) {
|
|
844
|
+
for (const ex of e.examples) {
|
|
845
|
+
expect(unhighlight(highlight(ex.html)), `${e.id}/"${ex.title}"`).toBe(ex.html)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// Review Focus 1. An example is free to contain markup-ish text; none of it
|
|
851
|
+
// may terminate the code block or inject an element into the page.
|
|
852
|
+
it('neutralises markup that would otherwise break out of the block', () => {
|
|
853
|
+
const nasty = `<p>a & b</p></pre><script>alert(1)</script>`
|
|
854
|
+
const out = highlight(nasty)
|
|
855
|
+
expect(out).not.toContain('</pre>')
|
|
856
|
+
expect(out).not.toContain('<script>')
|
|
857
|
+
expect(unhighlight(out)).toBe(nasty)
|
|
858
|
+
})
|
|
859
|
+
})
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
863
|
+
|
|
864
|
+
Run: `npx vitest run tests/highlight.test.mjs`
|
|
865
|
+
Expected: FAIL — cannot resolve `../docs/site/highlight.mjs`.
|
|
866
|
+
|
|
867
|
+
- [ ] **Step 3: Write the implementation**
|
|
868
|
+
|
|
869
|
+
Create `docs/site/highlight.mjs`:
|
|
870
|
+
|
|
871
|
+
```js
|
|
872
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
|
873
|
+
|
|
874
|
+
export function escapeHtml(source) {
|
|
875
|
+
return String(source).replace(/[&<>"]/g, (ch) => ESCAPES[ch])
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const ATTR = /(\s*)([a-zA-Z_:][\w:.-]*)(?:(\s*=\s*)("[^"]*"|'[^']*'))?/g
|
|
879
|
+
|
|
880
|
+
// A build-time tokeniser for HTML, and only HTML - the one language the
|
|
881
|
+
// samples are written in. A Prism or Shiki dependency would buy support for
|
|
882
|
+
// languages that never appear here, and would make every visitor pay at
|
|
883
|
+
// runtime for a transform that can happen once, here.
|
|
884
|
+
//
|
|
885
|
+
// The invariant, pinned by the round-trip test: every character of the input
|
|
886
|
+
// reaches the output exactly once, escaped. Highlighting may add spans; it may
|
|
887
|
+
// never add, drop or reorder source text. A `>` inside an attribute value
|
|
888
|
+
// would break the tag scan, so it is left unhighlighted as plain text rather
|
|
889
|
+
// than mis-parsed - the round-trip still holds.
|
|
890
|
+
export function highlight(source) {
|
|
891
|
+
let out = ''
|
|
892
|
+
let i = 0
|
|
893
|
+
const text = (s) => { out += escapeHtml(s) }
|
|
894
|
+
const span = (cls, s) => { out += `<span class="${cls}">${escapeHtml(s)}</span>` }
|
|
895
|
+
|
|
896
|
+
while (i < source.length) {
|
|
897
|
+
const lt = source.indexOf('<', i)
|
|
898
|
+
if (lt === -1) { text(source.slice(i)); break }
|
|
899
|
+
if (lt > i) text(source.slice(i, lt))
|
|
900
|
+
|
|
901
|
+
const gt = source.indexOf('>', lt)
|
|
902
|
+
const inner = gt === -1 ? null : source.slice(lt + 1, gt)
|
|
903
|
+
const head = inner && inner.match(/^(\/?)([a-zA-Z][\w-]*)([\s\S]*)$/)
|
|
904
|
+
if (!head) { text(source.slice(lt, lt + 1)); i = lt + 1; continue }
|
|
905
|
+
|
|
906
|
+
const [, slash, name, rest] = head
|
|
907
|
+
span('t-punc', `<${slash}`)
|
|
908
|
+
span('t-tag', name)
|
|
909
|
+
|
|
910
|
+
ATTR.lastIndex = 0
|
|
911
|
+
let cursor = 0
|
|
912
|
+
let match
|
|
913
|
+
while ((match = ATTR.exec(rest)) !== null) {
|
|
914
|
+
if (match[0] === '') { ATTR.lastIndex += 1; continue }
|
|
915
|
+
if (match.index > cursor) text(rest.slice(cursor, match.index))
|
|
916
|
+
text(match[1])
|
|
917
|
+
span('t-attr', match[2])
|
|
918
|
+
if (match[3]) span('t-punc', match[3])
|
|
919
|
+
if (match[4]) span('t-str', match[4])
|
|
920
|
+
cursor = ATTR.lastIndex
|
|
921
|
+
}
|
|
922
|
+
if (cursor < rest.length) {
|
|
923
|
+
const tail = rest.slice(cursor)
|
|
924
|
+
const selfClosing = tail.match(/^(\s*)(\/)$/)
|
|
925
|
+
if (selfClosing) { text(selfClosing[1]); span('t-punc', '/') } else text(tail)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
span('t-punc', '>')
|
|
929
|
+
i = gt + 1
|
|
930
|
+
}
|
|
931
|
+
return out
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
936
|
+
|
|
937
|
+
Run: `npx vitest run tests/highlight.test.mjs`
|
|
938
|
+
Expected: PASS, 6 tests. If the round-trip fails for one example, the tokeniser mis-parsed it — fix the tokeniser, never the example.
|
|
939
|
+
|
|
940
|
+
- [ ] **Step 5: Commit**
|
|
941
|
+
|
|
942
|
+
```bash
|
|
943
|
+
git add docs/site/highlight.mjs tests/highlight.test.mjs
|
|
944
|
+
git commit -m "$(cat <<'EOF'
|
|
945
|
+
feat(docs): escape and highlight example markup at build time
|
|
946
|
+
|
|
947
|
+
Four token classes drawn from existing inks - tag in meta, attribute in accent,
|
|
948
|
+
value in ok, punctuation in faint. HTML only, because HTML is the only language
|
|
949
|
+
the samples are written in, and a highlighter dependency would make every
|
|
950
|
+
visitor pay at runtime for a transform that belongs in the build.
|
|
951
|
+
|
|
952
|
+
The invariant is the round-trip: stripping the spans and unescaping returns the
|
|
953
|
+
source character for character, asserted against every example in the manifest.
|
|
954
|
+
That is what lets the code block be trusted as the thing the demo was rendered
|
|
955
|
+
from, rather than a retyped approximation of it.
|
|
956
|
+
|
|
957
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
958
|
+
EOF
|
|
959
|
+
)"
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
---
|
|
963
|
+
|
|
964
|
+
### Task 6: Page shell, the `url()` helper, and docs-only CSS
|
|
965
|
+
|
|
966
|
+
Every page's chrome, built from axi's own components, plus the single place a link is made. This is where the base-path trap is closed.
|
|
967
|
+
|
|
968
|
+
**Files:**
|
|
969
|
+
- Create: `docs/site/shell.mjs`, `docs/site/docs.css`, `docs/site/accent.js`, `docs/site/copy.js`
|
|
970
|
+
- Test: `tests/shell.test.mjs`
|
|
971
|
+
|
|
972
|
+
**Interfaces:**
|
|
973
|
+
- Consumes: `byLayer`, `LAYERS` from `docs/manifest/index.mjs`.
|
|
974
|
+
- Produces:
|
|
975
|
+
- `BASE: string` — `process.env.AXI_BASE ?? '/axi-design/'`.
|
|
976
|
+
- `url(path?: string): string` — the only way a link is built.
|
|
977
|
+
- `VERSION: string` — read from `package.json`.
|
|
978
|
+
- `LAYER_NAMES: Record<string, string>` — `{ primitives: 'Primitives', … }`.
|
|
979
|
+
- `page({ title, nav, body, toc?, sidebar?, description? }): string` — a full HTML document.
|
|
980
|
+
- `sidebar(currentId?: string): string` — the layer-grouped component nav.
|
|
981
|
+
|
|
982
|
+
- [ ] **Step 1: Write the failing test**
|
|
983
|
+
|
|
984
|
+
Create `tests/shell.test.mjs`:
|
|
985
|
+
|
|
986
|
+
```js
|
|
987
|
+
import { describe, it, expect } from 'vitest'
|
|
988
|
+
import { url, page, sidebar, LAYER_NAMES, VERSION } from '../docs/site/shell.mjs'
|
|
989
|
+
import { LAYERS } from '../docs/manifest/index.mjs'
|
|
990
|
+
|
|
991
|
+
describe('url', () => {
|
|
992
|
+
it('prefixes the base path', () => {
|
|
993
|
+
expect(url('components/meter/')).toBe('/axi-design/components/meter/')
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('tolerates a leading slash on the argument', () => {
|
|
997
|
+
expect(url('/start/')).toBe('/axi-design/start/')
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it('returns the base itself for the site root', () => {
|
|
1001
|
+
expect(url()).toBe('/axi-design/')
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
it('never emits a doubled slash', () => {
|
|
1005
|
+
expect(url('//components//')).not.toMatch(/\/\//)
|
|
1006
|
+
})
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
describe('page', () => {
|
|
1010
|
+
const html = page({ title: 'Meter', nav: 'components', body: '<p>hi</p>' })
|
|
1011
|
+
|
|
1012
|
+
it('is a complete document with the title in it', () => {
|
|
1013
|
+
expect(html).toMatch(/^<!doctype html>/i)
|
|
1014
|
+
expect(html).toContain('<title>Meter · axi-design</title>')
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
it('links the stylesheets through url()', () => {
|
|
1018
|
+
expect(html).toContain(`href="${url('axi.css')}"`)
|
|
1019
|
+
expect(html).toContain(`href="${url('accents.css')}"`)
|
|
1020
|
+
expect(html).toContain(`href="${url('docs.css')}"`)
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
it('shows the package version in the brand', () => {
|
|
1024
|
+
expect(html).toContain(`v${VERSION}`)
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('marks the current nav item', () => {
|
|
1028
|
+
expect(html).toMatch(/<a href="[^"]*components\/"[^>]*aria-current="page"/)
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
// Review Focus: a raw absolute link works locally and 404s on Pages, which
|
|
1032
|
+
// is the single most likely way this site ships silently broken.
|
|
1033
|
+
it('emits no absolute link that bypassed url()', () => {
|
|
1034
|
+
expect(html).not.toMatch(/(?:href|src)="\/(?!axi-design\/)/)
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
describe('sidebar', () => {
|
|
1039
|
+
it('names every populated layer', () => {
|
|
1040
|
+
const html = sidebar()
|
|
1041
|
+
for (const layer of LAYERS) {
|
|
1042
|
+
if (html.includes(`data-layer="${layer}"`)) expect(html).toContain(LAYER_NAMES[layer])
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
it('marks the current component and nothing else', () => {
|
|
1047
|
+
const html = sidebar('meter')
|
|
1048
|
+
expect([...html.matchAll(/aria-current="page"/g)].length).toBe(1)
|
|
1049
|
+
expect(html).toMatch(/href="[^"]*components\/meter\/"[^>]*aria-current="page"/)
|
|
1050
|
+
})
|
|
1051
|
+
})
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1055
|
+
|
|
1056
|
+
Run: `npx vitest run tests/shell.test.mjs`
|
|
1057
|
+
Expected: FAIL — cannot resolve `../docs/site/shell.mjs`.
|
|
1058
|
+
|
|
1059
|
+
- [ ] **Step 3: Write shell.mjs**
|
|
1060
|
+
|
|
1061
|
+
Create `docs/site/shell.mjs`:
|
|
1062
|
+
|
|
1063
|
+
```js
|
|
1064
|
+
import { readFileSync } from 'node:fs'
|
|
1065
|
+
import { resolve, dirname } from 'node:path'
|
|
1066
|
+
import { fileURLToPath } from 'node:url'
|
|
1067
|
+
import { byLayer } from '../manifest/index.mjs'
|
|
1068
|
+
|
|
1069
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
1070
|
+
|
|
1071
|
+
export const VERSION = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')).version
|
|
1072
|
+
export const ACCENTS = JSON.parse(readFileSync(resolve(ROOT, 'accents.json'), 'utf8'))
|
|
1073
|
+
|
|
1074
|
+
// The site is served from /axi-design/ on Pages and from / by `npm run serve`.
|
|
1075
|
+
// Every link in every emitted page goes through url(); a hand-written absolute
|
|
1076
|
+
// href works in exactly one of those two places and fails silently in the
|
|
1077
|
+
// other, which is why tests/site.test.mjs scans the output for one.
|
|
1078
|
+
export const BASE = process.env.AXI_BASE ?? '/axi-design/'
|
|
1079
|
+
|
|
1080
|
+
export function url(path = '') {
|
|
1081
|
+
return `${BASE}/${String(path).replace(/^\/+/, '')}`.replace(/\/{2,}/g, '/')
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export const LAYER_NAMES = {
|
|
1085
|
+
primitives: 'Primitives',
|
|
1086
|
+
layout: 'Layout',
|
|
1087
|
+
shells: 'Shells',
|
|
1088
|
+
data: 'Data',
|
|
1089
|
+
prose: 'Prose',
|
|
1090
|
+
utilities: 'Utilities',
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const NAV = [
|
|
1094
|
+
['start/', 'Start'],
|
|
1095
|
+
['rules/', 'Rules'],
|
|
1096
|
+
['theming/', 'Theming'],
|
|
1097
|
+
['components/', 'Components'],
|
|
1098
|
+
['gallery/', 'Gallery'],
|
|
1099
|
+
]
|
|
1100
|
+
|
|
1101
|
+
export function sidebar(currentId) {
|
|
1102
|
+
return byLayer().map(({ layer, items }) => {
|
|
1103
|
+
const links = items.map((e) => {
|
|
1104
|
+
const current = e.id === currentId ? ' aria-current="page"' : ''
|
|
1105
|
+
return `<a href="${url(`components/${e.id}/`)}"${current}>${e.name}</a>`
|
|
1106
|
+
}).join('\n ')
|
|
1107
|
+
return ` <h3 data-layer="${layer}">${LAYER_NAMES[layer]}</h3>\n ${links}`
|
|
1108
|
+
}).join('\n')
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function accentSelect() {
|
|
1112
|
+
const options = ACCENTS.map((a) => `<option value="${a.id}">${a.label}</option>`).join('')
|
|
1113
|
+
return `<label class="axi-sr-only" for="accent">Accent colour</label>
|
|
1114
|
+
<select class="axi-select" id="accent">${options}</select>`
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export function page({ title, nav, body, toc = '', sidebar: side = '', description = '' }) {
|
|
1118
|
+
const tabs = NAV.map(([href, label]) => {
|
|
1119
|
+
const current = href === `${nav}/` || href === nav ? ' aria-current="page"' : ''
|
|
1120
|
+
return `<a href="${url(href)}"${current}>${label}</a>`
|
|
1121
|
+
}).join('')
|
|
1122
|
+
|
|
1123
|
+
const columns = [side && `<aside class="docs-side">\n${side}\n </aside>`, `<main>${body}</main>`,
|
|
1124
|
+
toc && `<nav class="docs-toc"><h3>On this page</h3>${toc}</nav>`].filter(Boolean).join('\n ')
|
|
1125
|
+
|
|
1126
|
+
const shellClass = ['docs-shell', side ? '' : 'docs-shell--plain', toc ? '' : 'docs-shell--notoc']
|
|
1127
|
+
.filter(Boolean).join(' ')
|
|
1128
|
+
|
|
1129
|
+
return `<!doctype html>
|
|
1130
|
+
<html lang="en">
|
|
1131
|
+
<head>
|
|
1132
|
+
<meta charset="utf-8">
|
|
1133
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1134
|
+
<title>${title} · axi-design</title>
|
|
1135
|
+
${description ? `<meta name="description" content="${description}">` : ''}
|
|
1136
|
+
<link rel="stylesheet" href="${url('axi.css')}">
|
|
1137
|
+
<link rel="stylesheet" href="${url('accents.css')}">
|
|
1138
|
+
<link rel="stylesheet" href="${url('docs.css')}">
|
|
1139
|
+
</head>
|
|
1140
|
+
<body>
|
|
1141
|
+
<header class="axi-mast"><div class="axi-mast__in">
|
|
1142
|
+
<a class="axi-brand" href="${url()}">
|
|
1143
|
+
<span class="axi-sigil" aria-hidden="true">A</span>
|
|
1144
|
+
<span class="axi-brand__name">axi-design<small>v${VERSION}</small></span>
|
|
1145
|
+
</a>
|
|
1146
|
+
<nav class="axi-tabs">${tabs}</nav>
|
|
1147
|
+
<div class="axi-search"><span class="axi-search__icon" aria-hidden="true">⌕</span>
|
|
1148
|
+
<input class="axi-input" id="q" type="search" placeholder="Search components…" autocomplete="off"></div>
|
|
1149
|
+
<div class="docs-results" id="results" hidden></div>
|
|
1150
|
+
${accentSelect()}
|
|
1151
|
+
</div></header>
|
|
1152
|
+
<div class="${shellClass}">
|
|
1153
|
+
${columns}
|
|
1154
|
+
</div>
|
|
1155
|
+
<script type="module" src="${url('accent.js')}"></script>
|
|
1156
|
+
<script type="module" src="${url('copy.js')}"></script>
|
|
1157
|
+
<script type="module" src="${url('search.js')}"></script>
|
|
1158
|
+
</body>
|
|
1159
|
+
</html>
|
|
1160
|
+
`
|
|
1161
|
+
}
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
- [ ] **Step 4: Write accent.js**
|
|
1165
|
+
|
|
1166
|
+
Create `docs/site/accent.js`. It is an ES module so the selection logic can be imported by a test and still run in the browser:
|
|
1167
|
+
|
|
1168
|
+
```js
|
|
1169
|
+
// The accent is applied as a data attribute, which is exactly what
|
|
1170
|
+
// dist/accents.css already selects on. The docs site therefore themes itself
|
|
1171
|
+
// through the same published mechanism a consumer uses, rather than through a
|
|
1172
|
+
// bespoke inline style only this site knows about.
|
|
1173
|
+
const KEY = 'axi-accent'
|
|
1174
|
+
|
|
1175
|
+
// A persisted id can outlive the accent list: accents.json is versioned data
|
|
1176
|
+
// and a returning visitor's localStorage is not. An unrecognised id must fall
|
|
1177
|
+
// back to the first official accent rather than leave --axi-accent unset,
|
|
1178
|
+
// because an unset accent is not a degraded page, it is an unthemed one.
|
|
1179
|
+
export function chooseAccent(saved, validIds, fallbackId) {
|
|
1180
|
+
return validIds.includes(saved) ? saved : fallbackId
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (typeof document !== 'undefined') {
|
|
1184
|
+
const select = document.getElementById('accent')
|
|
1185
|
+
if (select) {
|
|
1186
|
+
const valid = [...select.options].map((o) => o.value)
|
|
1187
|
+
const chosen = chooseAccent(localStorage.getItem(KEY), valid, valid[0])
|
|
1188
|
+
document.documentElement.setAttribute('data-axi-accent', chosen)
|
|
1189
|
+
select.value = chosen
|
|
1190
|
+
select.addEventListener('change', () => {
|
|
1191
|
+
document.documentElement.setAttribute('data-axi-accent', select.value)
|
|
1192
|
+
localStorage.setItem(KEY, select.value)
|
|
1193
|
+
})
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
- [ ] **Step 5: Write copy.js**
|
|
1199
|
+
|
|
1200
|
+
Create `docs/site/copy.js`:
|
|
1201
|
+
|
|
1202
|
+
```js
|
|
1203
|
+
for (const button of document.querySelectorAll('[data-copy]')) {
|
|
1204
|
+
button.addEventListener('click', async () => {
|
|
1205
|
+
const code = document.getElementById(button.dataset.copy)
|
|
1206
|
+
await navigator.clipboard.writeText(code.textContent)
|
|
1207
|
+
const previous = button.textContent
|
|
1208
|
+
button.textContent = 'Copied'
|
|
1209
|
+
setTimeout(() => { button.textContent = previous }, 1200)
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
- [ ] **Step 6: Write docs.css**
|
|
1215
|
+
|
|
1216
|
+
Create `docs/site/docs.css` with exactly this. Every selector is `.docs-*` or one of the four `.t-*` highlight classes — nothing here may be a bare `.axi-*` rule, because this file is not part of the design language and never reaches `dist/`. Every colour and every weight resolves through an existing `--axi-*` token; there are no literals but layout measures.
|
|
1217
|
+
|
|
1218
|
+
```css
|
|
1219
|
+
/* axi-design documentation site — chrome only.
|
|
1220
|
+
Not part of the design language. Never concatenated into dist/axi.css,
|
|
1221
|
+
never published to npm. Anything in here that turns out to be generally
|
|
1222
|
+
useful is a candidate for promotion into src/, having earned its way in by
|
|
1223
|
+
being needed rather than by being on some other framework's feature list. */
|
|
1224
|
+
|
|
1225
|
+
body { margin: 0; }
|
|
1226
|
+
|
|
1227
|
+
.docs-shell {
|
|
1228
|
+
display: grid;
|
|
1229
|
+
grid-template-columns: 250px minmax(0, 1fr) 200px;
|
|
1230
|
+
gap: 34px;
|
|
1231
|
+
max-width: var(--axi-page-wide);
|
|
1232
|
+
margin: 0 auto;
|
|
1233
|
+
padding: 26px var(--axi-gutter) 80px;
|
|
1234
|
+
}
|
|
1235
|
+
.docs-shell--notoc { grid-template-columns: 250px minmax(0, 1fr); }
|
|
1236
|
+
.docs-shell--plain { grid-template-columns: minmax(0, 1fr); max-width: var(--axi-page); }
|
|
1237
|
+
|
|
1238
|
+
.docs-side { position: sticky; top: 20px; align-self: start; max-height: calc(100vh - 40px); overflow: auto; }
|
|
1239
|
+
.docs-side h3 {
|
|
1240
|
+
font: var(--axi-t-eyebrow); letter-spacing: var(--axi-ls-eyebrow);
|
|
1241
|
+
text-transform: uppercase; color: var(--axi-text-faint); margin: 22px 0 8px;
|
|
1242
|
+
}
|
|
1243
|
+
.docs-side h3:first-child { margin-top: 0; }
|
|
1244
|
+
.docs-side a {
|
|
1245
|
+
display: block; padding: 5px 10px; text-decoration: none;
|
|
1246
|
+
font: var(--axi-t-small); color: var(--axi-text-dim);
|
|
1247
|
+
border-left: var(--axi-border-control) solid transparent;
|
|
1248
|
+
}
|
|
1249
|
+
.docs-side a:hover { color: var(--axi-text); background: var(--axi-surface); }
|
|
1250
|
+
.docs-side a[aria-current] { color: var(--axi-accent-ink); background: var(--axi-accent); font-weight: 800; }
|
|
1251
|
+
|
|
1252
|
+
.docs-toc { position: sticky; top: 20px; align-self: start; }
|
|
1253
|
+
.docs-toc h3 {
|
|
1254
|
+
font: var(--axi-t-micro); letter-spacing: var(--axi-ls-micro);
|
|
1255
|
+
text-transform: uppercase; color: var(--axi-text-faint); margin: 0 0 10px;
|
|
1256
|
+
}
|
|
1257
|
+
.docs-toc a { display: block; padding: 4px 0; text-decoration: none; font: var(--axi-t-small); color: var(--axi-text-dim); }
|
|
1258
|
+
.docs-toc a:hover { color: var(--axi-accent); }
|
|
1259
|
+
|
|
1260
|
+
.docs-title { font: var(--axi-t-display); letter-spacing: var(--axi-ls-display); margin: 2px 0 10px; }
|
|
1261
|
+
.docs-lede { font: 400 17px/1.5 var(--axi-sans); color: var(--axi-text-dim); max-width: 60ch; margin: 0 0 18px; }
|
|
1262
|
+
.docs-h2 { font: var(--axi-t-h2); letter-spacing: var(--axi-ls-h2); margin: 38px 0 14px; }
|
|
1263
|
+
.docs-classrow { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 30px; }
|
|
1264
|
+
|
|
1265
|
+
.docs-ex { margin: 0 0 34px; }
|
|
1266
|
+
.docs-ex__h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 12px; }
|
|
1267
|
+
.docs-ex__h h2 { font: var(--axi-t-h3); letter-spacing: var(--axi-ls-h3); margin: 0; }
|
|
1268
|
+
.docs-ex__note { font: var(--axi-t-small); color: var(--axi-text-faint); }
|
|
1269
|
+
|
|
1270
|
+
.docs-demo {
|
|
1271
|
+
background: var(--axi-surface);
|
|
1272
|
+
border: var(--axi-border-panel) solid var(--axi-ink-line);
|
|
1273
|
+
box-shadow: var(--axi-offset-panel) var(--axi-offset-panel) 0 var(--axi-ink-line);
|
|
1274
|
+
padding: 28px;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/* Tucked against the demo's own offset so the pair reads as one object rather
|
|
1278
|
+
than two stacked panels. */
|
|
1279
|
+
.docs-code {
|
|
1280
|
+
position: relative;
|
|
1281
|
+
margin-top: calc(var(--axi-offset-panel) + 12px);
|
|
1282
|
+
background: var(--axi-ink-line);
|
|
1283
|
+
border: var(--axi-border-control) solid var(--axi-ink-line);
|
|
1284
|
+
}
|
|
1285
|
+
.docs-code pre { margin: 0; padding: 16px 18px; overflow: auto; font: 13px/1.65 var(--axi-mono); color: var(--axi-text-dim); }
|
|
1286
|
+
.docs-code__copy { position: absolute; top: 8px; right: 8px; }
|
|
1287
|
+
|
|
1288
|
+
.t-tag { color: var(--axi-meta); }
|
|
1289
|
+
.t-attr { color: var(--axi-accent); }
|
|
1290
|
+
.t-str { color: var(--axi-ok); }
|
|
1291
|
+
.t-punc { color: var(--axi-text-faint); }
|
|
1292
|
+
|
|
1293
|
+
.docs-knobs { width: 100%; border-collapse: collapse; margin: 0 0 30px; }
|
|
1294
|
+
.docs-knobs th {
|
|
1295
|
+
text-align: left; font: var(--axi-t-micro); letter-spacing: var(--axi-ls-micro);
|
|
1296
|
+
text-transform: uppercase; color: var(--axi-text-faint); padding: 0 14px 9px 0;
|
|
1297
|
+
}
|
|
1298
|
+
.docs-knobs td {
|
|
1299
|
+
padding: 9px 14px 9px 0; vertical-align: top;
|
|
1300
|
+
border-top: var(--axi-border-hairline) solid var(--axi-rule);
|
|
1301
|
+
font: var(--axi-t-small); color: var(--axi-text-dim);
|
|
1302
|
+
}
|
|
1303
|
+
.docs-knobs code { font: 12.5px/1.5 var(--axi-mono); color: var(--axi-accent); }
|
|
1304
|
+
|
|
1305
|
+
.docs-rules { display: flex; flex-direction: column; gap: 10px; }
|
|
1306
|
+
.docs-rules a { text-decoration: none; }
|
|
1307
|
+
|
|
1308
|
+
.docs-tile__demo { margin-top: 14px; pointer-events: none; }
|
|
1309
|
+
.docs-section { margin: 0 0 56px; }
|
|
1310
|
+
|
|
1311
|
+
/* Search results, anchored under the mast's input. */
|
|
1312
|
+
.docs-results {
|
|
1313
|
+
position: absolute; top: 100%; z-index: 20; width: 320px;
|
|
1314
|
+
background: var(--axi-surface-raised);
|
|
1315
|
+
border: var(--axi-border-control) solid var(--axi-ink-line);
|
|
1316
|
+
box-shadow: var(--axi-offset-control) var(--axi-offset-control) 0 var(--axi-ink-line);
|
|
1317
|
+
}
|
|
1318
|
+
.docs-results a {
|
|
1319
|
+
display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
|
|
1320
|
+
padding: 9px 12px; text-decoration: none; color: var(--axi-text); font: var(--axi-t-small);
|
|
1321
|
+
}
|
|
1322
|
+
.docs-results a:hover { background: var(--axi-accent); color: var(--axi-accent-ink); }
|
|
1323
|
+
.docs-results small { color: var(--axi-text-faint); font: var(--axi-t-micro); letter-spacing: var(--axi-ls-micro); text-transform: uppercase; }
|
|
1324
|
+
|
|
1325
|
+
@media (max-width: 1100px) {
|
|
1326
|
+
.docs-shell, .docs-shell--notoc { grid-template-columns: minmax(0, 1fr); }
|
|
1327
|
+
.docs-toc { display: none; }
|
|
1328
|
+
.docs-side { position: static; max-height: none; overflow: visible; }
|
|
1329
|
+
}
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
Note the `.docs-results` positioning depends on `.axi-search` establishing a containing block. If it does not, wrap the search input and results in a `<div style="position: relative">` in `shell.mjs` rather than adding a `position` rule for `.axi-search` here — this file may not restyle an axi component.
|
|
1333
|
+
|
|
1334
|
+
- [ ] **Step 7: Run test to verify it passes**
|
|
1335
|
+
|
|
1336
|
+
Run: `npx vitest run tests/shell.test.mjs`
|
|
1337
|
+
Expected: PASS, 10 tests.
|
|
1338
|
+
|
|
1339
|
+
- [ ] **Step 8: Commit**
|
|
1340
|
+
|
|
1341
|
+
```bash
|
|
1342
|
+
git add docs/site tests/shell.test.mjs
|
|
1343
|
+
git commit -m "$(cat <<'EOF'
|
|
1344
|
+
feat(docs): the page shell, in axi's own components, behind one url() helper
|
|
1345
|
+
|
|
1346
|
+
Mast, brand, sigil, tabs, search, select - the site's chrome is the design
|
|
1347
|
+
language, which is the strongest argument the site can make for it. Everything
|
|
1348
|
+
the language does not ship lives in docs.css under a .docs- prefix and never
|
|
1349
|
+
touches dist/.
|
|
1350
|
+
|
|
1351
|
+
Every link goes through url(). The site is served from /axi-design/ on Pages
|
|
1352
|
+
and from / locally, so a hand-written absolute href works in exactly one of the
|
|
1353
|
+
two and 404s in the other with no other symptom - the shell test scans for one.
|
|
1354
|
+
|
|
1355
|
+
The accent applies as a data attribute, which is what dist/accents.css already
|
|
1356
|
+
selects on: the site themes itself through the published mechanism rather than
|
|
1357
|
+
a bespoke one. A persisted id that is no longer in accents.json falls back to
|
|
1358
|
+
the first official accent instead of leaving the page unthemed.
|
|
1359
|
+
|
|
1360
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
1361
|
+
EOF
|
|
1362
|
+
)"
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
---
|
|
1366
|
+
|
|
1367
|
+
### Task 7: Component pages, the component index, and the generator entry point
|
|
1368
|
+
|
|
1369
|
+
The first task that produces a browsable site.
|
|
1370
|
+
|
|
1371
|
+
**Files:**
|
|
1372
|
+
- Create: `docs/site/render.mjs`, `scripts/site.mjs`
|
|
1373
|
+
- Modify: `package.json` (add the `docs` script), `.gitignore` (add `_site/`)
|
|
1374
|
+
- Test: `tests/site.test.mjs`
|
|
1375
|
+
|
|
1376
|
+
**Interfaces:**
|
|
1377
|
+
- Consumes: `entries`, `byLayer` (Task 2/3); `knobsFor` (Task 4); `highlight` (Task 5); `page`, `sidebar`, `url`, `LAYER_NAMES` (Task 6).
|
|
1378
|
+
- Produces:
|
|
1379
|
+
- `componentPage(entry): string`
|
|
1380
|
+
- `componentsIndex(): string`
|
|
1381
|
+
- `example(ex, index, entryId): string`
|
|
1382
|
+
- From `scripts/site.mjs`: `build(outDir: string): string[]` — writes the site, returns every path written, relative to `outDir`.
|
|
1383
|
+
|
|
1384
|
+
- [ ] **Step 1: Write the failing test**
|
|
1385
|
+
|
|
1386
|
+
Create `tests/site.test.mjs`:
|
|
1387
|
+
|
|
1388
|
+
```js
|
|
1389
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
1390
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
1391
|
+
import { tmpdir } from 'node:os'
|
|
1392
|
+
import { resolve } from 'node:path'
|
|
1393
|
+
import { build } from '../scripts/site.mjs'
|
|
1394
|
+
import { entries, findEntry } from '../docs/manifest/index.mjs'
|
|
1395
|
+
|
|
1396
|
+
let out, written
|
|
1397
|
+
beforeAll(() => {
|
|
1398
|
+
out = mkdtempSync(resolve(tmpdir(), 'axi-site-'))
|
|
1399
|
+
written = build(out)
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
const read = (p) => readFileSync(resolve(out, p), 'utf8')
|
|
1403
|
+
|
|
1404
|
+
describe('build', () => {
|
|
1405
|
+
it('writes a page for every component', () => {
|
|
1406
|
+
for (const e of entries()) expect(written).toContain(`components/${e.id}/index.html`)
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
it('writes the component index and copies the stylesheets', () => {
|
|
1410
|
+
expect(written).toContain('components/index.html')
|
|
1411
|
+
expect(written).toContain('axi.css')
|
|
1412
|
+
expect(written).toContain('accents.css')
|
|
1413
|
+
expect(written).toContain('docs.css')
|
|
1414
|
+
})
|
|
1415
|
+
})
|
|
1416
|
+
|
|
1417
|
+
describe('a component page', () => {
|
|
1418
|
+
const entry = findEntry('meter')
|
|
1419
|
+
let html
|
|
1420
|
+
beforeAll(() => { html = read('components/meter/index.html') })
|
|
1421
|
+
|
|
1422
|
+
it('leads with the layer, name and summary', () => {
|
|
1423
|
+
expect(html).toContain('<p class="axi-eyebrow">Data</p>')
|
|
1424
|
+
expect(html).toContain(entry.name)
|
|
1425
|
+
expect(html).toContain(entry.summary)
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
it('lists its classes as meta chips', () => {
|
|
1429
|
+
for (const cls of entry.classes) {
|
|
1430
|
+
expect(html).toContain(`<span class="axi-chip axi-chip--meta">${cls}</span>`)
|
|
1431
|
+
}
|
|
1432
|
+
})
|
|
1433
|
+
|
|
1434
|
+
it('renders each example live and as code', () => {
|
|
1435
|
+
for (const ex of entry.examples) {
|
|
1436
|
+
expect(html).toContain(ex.title)
|
|
1437
|
+
expect(html).toContain(ex.html) // the live demo, raw
|
|
1438
|
+
expect(html).toContain('<span class="t-tag">div</span>') // the same markup, highlighted
|
|
1439
|
+
}
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
it('shows only the knobs its entry cites', () => {
|
|
1443
|
+
expect(html).toContain('--axi-meter-v')
|
|
1444
|
+
expect(html).not.toContain('--axi-drawer-width')
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
it('cites its rules and deep-links to stable anchors', () => {
|
|
1448
|
+
for (const n of entry.rules) expect(html).toContain(`/rules/#rule-${n}`)
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
it('marks itself current in the sidebar', () => {
|
|
1452
|
+
expect(html).toMatch(/href="[^"]*components\/meter\/"[^>]*aria-current="page"/)
|
|
1453
|
+
})
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
describe('emitted links', () => {
|
|
1457
|
+
it('contains no absolute link that bypassed url()', () => {
|
|
1458
|
+
for (const path of written.filter((p) => p.endsWith('.html'))) {
|
|
1459
|
+
const html = read(path)
|
|
1460
|
+
const offenders = [...html.matchAll(/(?:href|src)="\/(?!axi-design\/)[^"]*"/g)].map((m) => m[0])
|
|
1461
|
+
expect(offenders, `${path} has links outside the base path`).toEqual([])
|
|
1462
|
+
}
|
|
1463
|
+
})
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
afterAll(() => rmSync(out, { recursive: true, force: true }))
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
Add `afterAll` to the vitest import.
|
|
1470
|
+
|
|
1471
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1472
|
+
|
|
1473
|
+
Run: `npx vitest run tests/site.test.mjs`
|
|
1474
|
+
Expected: FAIL — cannot resolve `../scripts/site.mjs`.
|
|
1475
|
+
|
|
1476
|
+
- [ ] **Step 3: Write render.mjs**
|
|
1477
|
+
|
|
1478
|
+
Create `docs/site/render.mjs`:
|
|
1479
|
+
|
|
1480
|
+
```js
|
|
1481
|
+
import { byLayer } from '../manifest/index.mjs'
|
|
1482
|
+
import { knobsFor } from '../manifest/knobs.mjs'
|
|
1483
|
+
import { highlight } from './highlight.mjs'
|
|
1484
|
+
import { url, sidebar, page, LAYER_NAMES } from './shell.mjs'
|
|
1485
|
+
import { renderMarkdown } from './markdown.mjs'
|
|
1486
|
+
|
|
1487
|
+
// An example is one string, rendered twice: raw into the demo, escaped and
|
|
1488
|
+
// highlighted into the code block. There is no second copy of the markup, and
|
|
1489
|
+
// so no way for the code someone copies to disagree with the thing they are
|
|
1490
|
+
// looking at. That single-sourcing is the reason the manifest exists at all.
|
|
1491
|
+
export function example(ex, index, entryId) {
|
|
1492
|
+
const codeId = `code-${entryId}-${index}`
|
|
1493
|
+
const note = ex.note ? `<span class="docs-ex__note">${ex.note}</span>` : ''
|
|
1494
|
+
return `<section class="docs-ex" id="ex-${index}">
|
|
1495
|
+
<div class="docs-ex__h"><h2>${ex.title}</h2>${note}</div>
|
|
1496
|
+
<div class="docs-demo">${ex.html}</div>
|
|
1497
|
+
<div class="docs-code">
|
|
1498
|
+
<button class="axi-btn axi-btn--ghost docs-code__copy" type="button" data-copy="${codeId}">Copy</button>
|
|
1499
|
+
<pre><code id="${codeId}">${highlight(ex.html)}</code></pre>
|
|
1500
|
+
</div>
|
|
1501
|
+
</section>`
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function knobTable(names) {
|
|
1505
|
+
const knobs = knobsFor(names)
|
|
1506
|
+
if (!knobs.length) return ''
|
|
1507
|
+
const rows = knobs.map((k) => `<tr><td><code>${k.name}</code></td><td>${k.sets.replace(/`([^`]+)`/g, '<code>$1</code>')}</td><td>${k.fallback.replace(/`([^`]+)`/g, '<code>$1</code>')}</td></tr>`).join('\n')
|
|
1508
|
+
return `<h2 class="docs-h2" id="knobs">Knobs</h2>
|
|
1509
|
+
<table class="docs-knobs"><tr><th>Property</th><th>Sets</th><th>Fallback</th></tr>
|
|
1510
|
+
${rows}
|
|
1511
|
+
</table>`
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function ruleNotices(numbers, ruleTitles) {
|
|
1515
|
+
if (!numbers.length) return ''
|
|
1516
|
+
const items = numbers.map((n) => `<a class="axi-notice" href="${url(`rules/#rule-${n}`)}">
|
|
1517
|
+
<span class="axi-notice__icon" aria-hidden="true">${n}</span>
|
|
1518
|
+
<div><strong>${ruleTitles.get(n) ?? `Rule ${n}`}</strong></div>
|
|
1519
|
+
</a>`).join('\n')
|
|
1520
|
+
return `<h2 class="docs-h2" id="rules">Rules this answers</h2>
|
|
1521
|
+
<div class="docs-rules">${items}</div>`
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
export function componentPage(entry, ruleTitles) {
|
|
1525
|
+
const chips = entry.classes.map((c) => `<span class="axi-chip axi-chip--meta">${c}</span>`).join('\n ')
|
|
1526
|
+
const notes = entry.notes ? `<div class="axi-prose">${renderMarkdown(entry.notes).html}</div>` : ''
|
|
1527
|
+
const examples = entry.examples.map((ex, i) => example(ex, i, entry.id)).join('\n')
|
|
1528
|
+
|
|
1529
|
+
const toc = [
|
|
1530
|
+
...entry.examples.map((ex, i) => `<a href="#ex-${i}">${ex.title}</a>`),
|
|
1531
|
+
entry.knobs.length ? '<a href="#knobs">Knobs</a>' : '',
|
|
1532
|
+
entry.rules.length ? '<a href="#rules">Rules this answers</a>' : '',
|
|
1533
|
+
].filter(Boolean).join('')
|
|
1534
|
+
|
|
1535
|
+
const body = `<p class="axi-eyebrow">${LAYER_NAMES[entry.layer]}</p>
|
|
1536
|
+
<h1 class="docs-title">${entry.name}</h1>
|
|
1537
|
+
<p class="docs-lede">${entry.summary}</p>
|
|
1538
|
+
<div class="docs-classrow">
|
|
1539
|
+
${chips}
|
|
1540
|
+
</div>
|
|
1541
|
+
${notes}
|
|
1542
|
+
${examples}
|
|
1543
|
+
${knobTable(entry.knobs)}
|
|
1544
|
+
${ruleNotices(entry.rules, ruleTitles)}`
|
|
1545
|
+
|
|
1546
|
+
return page({
|
|
1547
|
+
title: entry.name,
|
|
1548
|
+
nav: 'components/',
|
|
1549
|
+
description: entry.summary,
|
|
1550
|
+
sidebar: sidebar(entry.id),
|
|
1551
|
+
toc,
|
|
1552
|
+
body,
|
|
1553
|
+
})
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
export function componentsIndex() {
|
|
1557
|
+
const groups = byLayer().map(({ layer, items }) => {
|
|
1558
|
+
const tiles = items.map((e) => `<a class="axi-card docs-tile" href="${url(`components/${e.id}/`)}">
|
|
1559
|
+
<div class="axi-card__head"><h3 class="axi-card__title">${e.name}</h3></div>
|
|
1560
|
+
<p class="axi-card__meta">${e.summary}</p>
|
|
1561
|
+
<div class="docs-tile__demo">${e.examples[0].html}</div>
|
|
1562
|
+
</a>`).join('\n')
|
|
1563
|
+
return `<h2 class="docs-h2" id="${layer}">${LAYER_NAMES[layer]}</h2>
|
|
1564
|
+
<div class="axi-grid">${tiles}</div>`
|
|
1565
|
+
}).join('\n')
|
|
1566
|
+
|
|
1567
|
+
return page({
|
|
1568
|
+
title: 'Components',
|
|
1569
|
+
nav: 'components/',
|
|
1570
|
+
description: 'Every component in the axi design language, grouped by layer.',
|
|
1571
|
+
sidebar: sidebar(),
|
|
1572
|
+
toc: byLayer().map(({ layer }) => `<a href="#${layer}">${LAYER_NAMES[layer]}</a>`).join(''),
|
|
1573
|
+
body: `<p class="axi-eyebrow">Reference</p>
|
|
1574
|
+
<h1 class="docs-title">Components</h1>
|
|
1575
|
+
<p class="docs-lede">Every component in the language, grouped by layer. Each page carries live examples, the markup that produced them, its knobs, and the rules it answers.</p>
|
|
1576
|
+
${groups}`,
|
|
1577
|
+
})
|
|
1578
|
+
}
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
Note: `renderMarkdown` arrives in Task 8. For this task, stub it at the top of `render.mjs` as `const renderMarkdown = (md) => ({ html: `<p>${md}</p>` })` and replace the stub with the real import in Task 8.
|
|
1582
|
+
|
|
1583
|
+
- [ ] **Step 4: Write scripts/site.mjs**
|
|
1584
|
+
|
|
1585
|
+
Create `scripts/site.mjs`:
|
|
1586
|
+
|
|
1587
|
+
```js
|
|
1588
|
+
import { mkdirSync, writeFileSync, copyFileSync, readFileSync } from 'node:fs'
|
|
1589
|
+
import { resolve, dirname } from 'node:path'
|
|
1590
|
+
import { fileURLToPath } from 'node:url'
|
|
1591
|
+
import { entries } from '../docs/manifest/index.mjs'
|
|
1592
|
+
import { componentPage, componentsIndex } from '../docs/site/render.mjs'
|
|
1593
|
+
|
|
1594
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
1595
|
+
|
|
1596
|
+
// The titles behind the rule numbers a component cites, so a "Rules this
|
|
1597
|
+
// answers" notice can name the rule rather than only number it. Read from
|
|
1598
|
+
// RULES.md so a reworded rule reads correctly everywhere without a second edit.
|
|
1599
|
+
function ruleTitles() {
|
|
1600
|
+
const md = readFileSync(resolve(ROOT, 'docs/RULES.md'), 'utf8')
|
|
1601
|
+
return new Map([...md.matchAll(/^## (\d+)\. (.+)$/gm)].map((m) => [Number(m[1]), m[2]]))
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
export function build(outDir) {
|
|
1605
|
+
const written = []
|
|
1606
|
+
const write = (rel, contents) => {
|
|
1607
|
+
const target = resolve(outDir, rel)
|
|
1608
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
1609
|
+
writeFileSync(target, contents)
|
|
1610
|
+
written.push(rel)
|
|
1611
|
+
}
|
|
1612
|
+
const copy = (from, rel) => {
|
|
1613
|
+
const target = resolve(outDir, rel)
|
|
1614
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
1615
|
+
copyFileSync(resolve(ROOT, from), target)
|
|
1616
|
+
written.push(rel)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const titles = ruleTitles()
|
|
1620
|
+
for (const entry of entries()) {
|
|
1621
|
+
write(`components/${entry.id}/index.html`, componentPage(entry, titles))
|
|
1622
|
+
}
|
|
1623
|
+
write('components/index.html', componentsIndex())
|
|
1624
|
+
|
|
1625
|
+
copy('dist/axi.css', 'axi.css')
|
|
1626
|
+
copy('dist/accents.css', 'accents.css')
|
|
1627
|
+
copy('docs/site/docs.css', 'docs.css')
|
|
1628
|
+
copy('docs/site/accent.js', 'accent.js')
|
|
1629
|
+
copy('docs/site/copy.js', 'copy.js')
|
|
1630
|
+
copy('docs/site/search.js', 'search.js')
|
|
1631
|
+
|
|
1632
|
+
return written
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
1636
|
+
const out = resolve(ROOT, '_site')
|
|
1637
|
+
const written = build(out)
|
|
1638
|
+
console.log(`built _site from ${entries().length} components (${written.length} files)`)
|
|
1639
|
+
}
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
`search.js` is created in Task 10; until then create `docs/site/search.js` as an empty file so the copy resolves.
|
|
1643
|
+
|
|
1644
|
+
- [ ] **Step 5: Add the npm script and ignore the output**
|
|
1645
|
+
|
|
1646
|
+
In `package.json`, add to `scripts`: `"docs": "node scripts/site.mjs"`.
|
|
1647
|
+
In `.gitignore`, add a line: `_site/`.
|
|
1648
|
+
|
|
1649
|
+
- [ ] **Step 6: Run the tests**
|
|
1650
|
+
|
|
1651
|
+
Run: `npx vitest run tests/site.test.mjs`
|
|
1652
|
+
Expected: PASS.
|
|
1653
|
+
|
|
1654
|
+
- [ ] **Step 7: Build and eyeball it**
|
|
1655
|
+
|
|
1656
|
+
Run: `npm run docs && ls _site/components`
|
|
1657
|
+
Expected: one directory per component, plus `index.html`.
|
|
1658
|
+
|
|
1659
|
+
- [ ] **Step 8: Commit**
|
|
1660
|
+
|
|
1661
|
+
```bash
|
|
1662
|
+
git add docs/site/render.mjs docs/site/search.js scripts/site.mjs package.json .gitignore tests/site.test.mjs
|
|
1663
|
+
git commit -m "$(cat <<'EOF'
|
|
1664
|
+
feat(docs): generate a page per component, and the index that gathers them
|
|
1665
|
+
|
|
1666
|
+
Each page: the layer, the name, the summary, the class names as chips right
|
|
1667
|
+
under the title where they are being looked for, then the examples - each one
|
|
1668
|
+
rendered live and, immediately beneath, the exact markup that produced it.
|
|
1669
|
+
Knobs filtered to the ones the entry cites. Then the rules the component
|
|
1670
|
+
answers, each deep-linked into RULES.md.
|
|
1671
|
+
|
|
1672
|
+
That last section is what makes this a design language rather than a component
|
|
1673
|
+
list: every page ends by pointing at the clauses that dictate why the thing
|
|
1674
|
+
looks the way it does.
|
|
1675
|
+
|
|
1676
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
1677
|
+
EOF
|
|
1678
|
+
)"
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
---
|
|
1682
|
+
|
|
1683
|
+
### Task 8: Markdown pipeline and the three narrative pages
|
|
1684
|
+
|
|
1685
|
+
`/start/`, `/theming/` and `/rules/`. RULES.md is rendered, never copied.
|
|
1686
|
+
|
|
1687
|
+
**Files:**
|
|
1688
|
+
- Create: `docs/site/markdown.mjs`, `docs/pages/start.md`, `docs/pages/theming.md`
|
|
1689
|
+
- Modify: `docs/site/render.mjs` (replace the `renderMarkdown` stub with the real import), `scripts/site.mjs` (emit the three pages), `package.json` (add `marked`)
|
|
1690
|
+
- Test: `tests/markdown.test.mjs`
|
|
1691
|
+
|
|
1692
|
+
**Interfaces:**
|
|
1693
|
+
- Consumes: `buildKnobTable` (Task 4); `ACCENTS` from `shell.mjs`.
|
|
1694
|
+
- Produces:
|
|
1695
|
+
- `renderMarkdown(md: string, { stableRuleIds?: boolean } = {}): { html: string, toc: { id, text }[] }`
|
|
1696
|
+
- `PLACEHOLDERS: Record<string, () => string>` — `knobs`, `accents`.
|
|
1697
|
+
|
|
1698
|
+
- [ ] **Step 1: Install marked**
|
|
1699
|
+
|
|
1700
|
+
Run: `npm install --save-dev marked`
|
|
1701
|
+
Then confirm it landed in `devDependencies` only: `node -e "const p=require('./package.json'); console.log(p.dependencies ?? 'no dependencies block', Object.keys(p.devDependencies))"`
|
|
1702
|
+
Expected: no `dependencies` block, `marked` present in `devDependencies`.
|
|
1703
|
+
|
|
1704
|
+
- [ ] **Step 2: Write the failing test**
|
|
1705
|
+
|
|
1706
|
+
Create `tests/markdown.test.mjs`:
|
|
1707
|
+
|
|
1708
|
+
```js
|
|
1709
|
+
import { describe, it, expect } from 'vitest'
|
|
1710
|
+
import { readFileSync } from 'node:fs'
|
|
1711
|
+
import { renderMarkdown } from '../docs/site/markdown.mjs'
|
|
1712
|
+
import { buildKnobTable } from '../scripts/build.mjs'
|
|
1713
|
+
|
|
1714
|
+
describe('renderMarkdown', () => {
|
|
1715
|
+
it('renders headings with slug ids and collects a toc', () => {
|
|
1716
|
+
const { html, toc } = renderMarkdown('## Getting started\n\ntext')
|
|
1717
|
+
expect(html).toContain('<h2 id="getting-started">Getting started</h2>')
|
|
1718
|
+
expect(toc).toEqual([{ id: 'getting-started', text: 'Getting started' }])
|
|
1719
|
+
})
|
|
1720
|
+
|
|
1721
|
+
// marked derives an id from the heading TEXT. Every component page deep-links
|
|
1722
|
+
// to /rules/#rule-<n>, so deriving the anchor from the wording would break
|
|
1723
|
+
// every one of those links the moment a rule is reworded - silently, since a
|
|
1724
|
+
// dead fragment does not 404.
|
|
1725
|
+
it('gives numbered RULES headings a stable rule-<n> id', () => {
|
|
1726
|
+
const { html } = renderMarkdown('## 2. No colour at partial opacity', { stableRuleIds: true })
|
|
1727
|
+
expect(html).toContain('<h2 id="rule-2">')
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
it('leaves unnumbered headings on slug ids even in rules mode', () => {
|
|
1731
|
+
const { html } = renderMarkdown('## Tokens', { stableRuleIds: true })
|
|
1732
|
+
expect(html).toContain('<h2 id="tokens">')
|
|
1733
|
+
})
|
|
1734
|
+
|
|
1735
|
+
it('substitutes a known placeholder', () => {
|
|
1736
|
+
const { html } = renderMarkdown('before\n\n<!-- axi:knobs -->\n\nafter')
|
|
1737
|
+
expect(html).toContain(buildKnobTable().split('\n')[0].slice(0, 12))
|
|
1738
|
+
expect(html).not.toContain('axi:knobs')
|
|
1739
|
+
})
|
|
1740
|
+
|
|
1741
|
+
// Review Focus: a guide that SHOWS the placeholder must display it, not have
|
|
1742
|
+
// it substituted. Substitution runs on rendered HTML, where a fenced block's
|
|
1743
|
+
// contents are already escaped, so the raw comment only survives outside one.
|
|
1744
|
+
it('leaves a placeholder inside a fenced code block alone', () => {
|
|
1745
|
+
const { html } = renderMarkdown('```\n<!-- axi:knobs -->\n```')
|
|
1746
|
+
expect(html).toContain('<!-- axi:knobs -->')
|
|
1747
|
+
expect(html).not.toContain('<th>Knob</th>')
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
it('throws on an unrecognised placeholder rather than passing it through', () => {
|
|
1751
|
+
expect(() => renderMarkdown('<!-- axi:nonsense -->')).toThrow(/axi:nonsense/)
|
|
1752
|
+
})
|
|
1753
|
+
|
|
1754
|
+
it('renders the real RULES.md without throwing', () => {
|
|
1755
|
+
const md = readFileSync('docs/RULES.md', 'utf8')
|
|
1756
|
+
const { html, toc } = renderMarkdown(md, { stableRuleIds: true })
|
|
1757
|
+
expect(html).toContain('id="rule-1"')
|
|
1758
|
+
expect(html).toContain('id="rule-11"')
|
|
1759
|
+
expect(toc.length).toBeGreaterThan(10)
|
|
1760
|
+
})
|
|
1761
|
+
})
|
|
1762
|
+
```
|
|
1763
|
+
|
|
1764
|
+
- [ ] **Step 3: Run test to verify it fails**
|
|
1765
|
+
|
|
1766
|
+
Run: `npx vitest run tests/markdown.test.mjs`
|
|
1767
|
+
Expected: FAIL — cannot resolve `../docs/site/markdown.mjs`.
|
|
1768
|
+
|
|
1769
|
+
- [ ] **Step 4: Write markdown.mjs**
|
|
1770
|
+
|
|
1771
|
+
Create `docs/site/markdown.mjs`:
|
|
1772
|
+
|
|
1773
|
+
```js
|
|
1774
|
+
import { marked } from 'marked'
|
|
1775
|
+
import { buildKnobTable } from '../../scripts/build.mjs'
|
|
1776
|
+
import { ACCENTS } from './shell.mjs'
|
|
1777
|
+
|
|
1778
|
+
// Generated content a Markdown page can embed. A guide that needs the knob
|
|
1779
|
+
// table gets the real one rather than a hand-copied second version, which is
|
|
1780
|
+
// the same bargain the README markers make.
|
|
1781
|
+
export const PLACEHOLDERS = {
|
|
1782
|
+
knobs: () => marked.parse(buildKnobTable()),
|
|
1783
|
+
accents: () => `<table class="docs-knobs"><tr><th>Accent</th><th>Id</th><th>Hex</th></tr>${
|
|
1784
|
+
ACCENTS.map((a) => `<tr><td><span class="axi-diamond" style="--axi-series: ${a.hex}"></span> ${a.label}</td><td><code>${a.id}</code></td><td><code>${a.hex}</code></td></tr>`).join('')
|
|
1785
|
+
}</table>`,
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const slug = (text) => text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '')
|
|
1789
|
+
|
|
1790
|
+
export function renderMarkdown(md, { stableRuleIds = false } = {}) {
|
|
1791
|
+
let html = marked.parse(md, { mangle: false, headerIds: false })
|
|
1792
|
+
const toc = []
|
|
1793
|
+
|
|
1794
|
+
// marked does not emit heading ids, which leaves the anchor scheme entirely
|
|
1795
|
+
// ours: a numbered RULES clause gets `rule-<n>`, stable across every
|
|
1796
|
+
// rewording, and everything else gets a slug.
|
|
1797
|
+
html = html.replace(/<h([23])>([\s\S]*?)<\/h\1>/g, (_, level, inner) => {
|
|
1798
|
+
const text = inner.replace(/<[^>]*>/g, '').trim()
|
|
1799
|
+
const numbered = stableRuleIds && text.match(/^(\d+)\./)
|
|
1800
|
+
const id = numbered ? `rule-${numbered[1]}` : slug(text)
|
|
1801
|
+
if (level === '2') toc.push({ id, text })
|
|
1802
|
+
return `<h${level} id="${id}">${inner}</h${level}>`
|
|
1803
|
+
})
|
|
1804
|
+
|
|
1805
|
+
// Substitution runs on the RENDERED html on purpose. Inside a fenced code
|
|
1806
|
+
// block marked has already escaped the comment to `<!-- axi:knobs -->`,
|
|
1807
|
+
// so a guide that shows a placeholder displays it and a guide that uses one
|
|
1808
|
+
// gets it filled - with no special-casing of fences anywhere here.
|
|
1809
|
+
html = html.replace(/<!--\s*axi:([a-z-]+)\s*-->/g, (_, name) => {
|
|
1810
|
+
const render = PLACEHOLDERS[name]
|
|
1811
|
+
if (!render) throw new Error(`unknown placeholder axi:${name} — known: ${Object.keys(PLACEHOLDERS).join(', ')}`)
|
|
1812
|
+
return render()
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
return { html, toc }
|
|
1816
|
+
}
|
|
1817
|
+
```
|
|
1818
|
+
|
|
1819
|
+
- [ ] **Step 5: Replace the stub in render.mjs**
|
|
1820
|
+
|
|
1821
|
+
In `docs/site/render.mjs`, delete the `const renderMarkdown = …` stub added in Task 7 and keep the real `import { renderMarkdown } from './markdown.mjs'`.
|
|
1822
|
+
|
|
1823
|
+
- [ ] **Step 6: Write the two Markdown guides**
|
|
1824
|
+
|
|
1825
|
+
Create `docs/pages/start.md` — the three consumption modes, ported from `README.md`'s "Use it" section: the Pages `<link>`, the npm install with `axi.css`, and the `tokens.css`-only case, including the paragraph explaining why a `<link>` is right for a site and wrong for an Electron window. End with setting `--axi-accent`.
|
|
1826
|
+
|
|
1827
|
+
Create `docs/pages/theming.md` — the token layers from `docs/RULES.md`'s "Tokens" section, then `<!-- axi:accents -->`, then the per-instance knob surface introduced in prose and `<!-- axi:knobs -->`. Make the distinction explicit: tokens are the system, knobs answer "how wide is *this* grid".
|
|
1828
|
+
|
|
1829
|
+
- [ ] **Step 7: Emit the three pages from site.mjs**
|
|
1830
|
+
|
|
1831
|
+
In `scripts/site.mjs`, add above the `copy(...)` calls:
|
|
1832
|
+
|
|
1833
|
+
```js
|
|
1834
|
+
const guide = (rel, source, title, nav, opts = {}) => {
|
|
1835
|
+
const { html, toc } = renderMarkdown(readFileSync(resolve(ROOT, source), 'utf8'), opts)
|
|
1836
|
+
write(rel, page({
|
|
1837
|
+
title,
|
|
1838
|
+
nav,
|
|
1839
|
+
body: `<div class="axi-prose">${html}</div>`,
|
|
1840
|
+
toc: toc.map((h) => `<a href="#${h.id}">${h.text}</a>`).join(''),
|
|
1841
|
+
}))
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
guide('start/index.html', 'docs/pages/start.md', 'Start', 'start/')
|
|
1845
|
+
guide('theming/index.html', 'docs/pages/theming.md', 'Theming', 'theming/')
|
|
1846
|
+
guide('rules/index.html', 'docs/RULES.md', 'Rules', 'rules/', { stableRuleIds: true })
|
|
1847
|
+
```
|
|
1848
|
+
|
|
1849
|
+
with `import { renderMarkdown } from '../docs/site/markdown.mjs'` and `import { page } from '../docs/site/shell.mjs'` at the top.
|
|
1850
|
+
|
|
1851
|
+
- [ ] **Step 8: Add the coverage assertion to site.test.mjs**
|
|
1852
|
+
|
|
1853
|
+
Append to `tests/site.test.mjs`:
|
|
1854
|
+
|
|
1855
|
+
```js
|
|
1856
|
+
describe('narrative pages', () => {
|
|
1857
|
+
it('writes start, theming and rules', () => {
|
|
1858
|
+
expect(written).toContain('start/index.html')
|
|
1859
|
+
expect(written).toContain('theming/index.html')
|
|
1860
|
+
expect(written).toContain('rules/index.html')
|
|
1861
|
+
})
|
|
1862
|
+
|
|
1863
|
+
it('renders RULES.md with the anchors components link to', () => {
|
|
1864
|
+
const html = read('rules/index.html')
|
|
1865
|
+
for (const n of [1, 2, 3, 9]) expect(html).toContain(`id="rule-${n}"`)
|
|
1866
|
+
})
|
|
1867
|
+
|
|
1868
|
+
// Every "Rules this answers" link on every component page must resolve to a
|
|
1869
|
+
// real anchor on /rules/. Nothing else would report a dead fragment.
|
|
1870
|
+
it('resolves every rule deep-link a component page emits', () => {
|
|
1871
|
+
const rules = read('rules/index.html')
|
|
1872
|
+
for (const e of entries()) {
|
|
1873
|
+
const html = read(`components/${e.id}/index.html`)
|
|
1874
|
+
for (const m of html.matchAll(/rules\/#(rule-\d+)/g)) {
|
|
1875
|
+
expect(rules, `${e.id} links #${m[1]}, absent from /rules/`).toContain(`id="${m[1]}"`)
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
})
|
|
1879
|
+
})
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
- [ ] **Step 9: Run the suite**
|
|
1883
|
+
|
|
1884
|
+
Run: `npm test`
|
|
1885
|
+
Expected: PASS.
|
|
1886
|
+
|
|
1887
|
+
- [ ] **Step 10: Commit**
|
|
1888
|
+
|
|
1889
|
+
```bash
|
|
1890
|
+
git add docs/site/markdown.mjs docs/site/render.mjs docs/pages scripts/site.mjs package.json package-lock.json tests/markdown.test.mjs tests/site.test.mjs
|
|
1891
|
+
git commit -m "$(cat <<'EOF'
|
|
1892
|
+
feat(docs): render the guides from Markdown, and RULES.md from RULES.md
|
|
1893
|
+
|
|
1894
|
+
/rules/ reads docs/RULES.md itself. There is no second copy to drift, and the
|
|
1895
|
+
file stays what ships in the tarball and what tests/tokens.test.mjs is written
|
|
1896
|
+
against.
|
|
1897
|
+
|
|
1898
|
+
Heading anchors are ours rather than marked's, because marked derives an id
|
|
1899
|
+
from the heading text and every component page deep-links to /rules/#rule-<n>:
|
|
1900
|
+
anchoring on wording would break every one of those links the moment a rule was
|
|
1901
|
+
reworded, and a dead fragment does not 404, so nothing would say so.
|
|
1902
|
+
|
|
1903
|
+
Generated tables reach a guide through an <!-- axi:knobs --> placeholder,
|
|
1904
|
+
substituted on the rendered HTML. Inside a fenced block marked has already
|
|
1905
|
+
escaped the comment, so a guide that demonstrates a placeholder displays it and
|
|
1906
|
+
a guide that uses one gets it filled, with no fence special-casing at all. An
|
|
1907
|
+
unknown placeholder throws.
|
|
1908
|
+
|
|
1909
|
+
marked is a devDependency: build-time only, never in dist/, never in the tarball.
|
|
1910
|
+
|
|
1911
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
1912
|
+
EOF
|
|
1913
|
+
)"
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
---
|
|
1917
|
+
|
|
1918
|
+
### Task 9: Landing page, and the gallery moves
|
|
1919
|
+
|
|
1920
|
+
`/` becomes the pitch; today's `index.html` becomes `/gallery/`.
|
|
1921
|
+
|
|
1922
|
+
**Files:**
|
|
1923
|
+
- Create: `docs/site/landing.mjs`
|
|
1924
|
+
- Modify: `index.html` (strip the old mast, keep the sections), `gallery.js` (remove the accent switcher), `scripts/site.mjs`, `tests/accents.test.mjs` (the gallery accent-select assertion moves)
|
|
1925
|
+
- Test: `tests/site.test.mjs`, `tests/accent.test.mjs`
|
|
1926
|
+
|
|
1927
|
+
**Interfaces:**
|
|
1928
|
+
- Consumes: `page`, `url`, `ACCENTS` (Task 6); `findEntry` (Task 2).
|
|
1929
|
+
- Produces: `landing(): string`.
|
|
1930
|
+
|
|
1931
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1932
|
+
|
|
1933
|
+
Create `tests/accent.test.mjs`:
|
|
1934
|
+
|
|
1935
|
+
```js
|
|
1936
|
+
import { describe, it, expect } from 'vitest'
|
|
1937
|
+
import { chooseAccent } from '../docs/site/accent.js'
|
|
1938
|
+
import { ACCENTS } from '../docs/site/shell.mjs'
|
|
1939
|
+
|
|
1940
|
+
describe('chooseAccent', () => {
|
|
1941
|
+
const valid = ACCENTS.map((a) => a.id)
|
|
1942
|
+
|
|
1943
|
+
it('keeps a persisted accent that is still official', () => {
|
|
1944
|
+
expect(chooseAccent('violet-purple', valid, valid[0])).toBe('violet-purple')
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1947
|
+
// Review Focus: accents.json is versioned data and a returning visitor's
|
|
1948
|
+
// localStorage is not. An id dropped from the list must not leave
|
|
1949
|
+
// --axi-accent unset - that is not a degraded page, it is an unthemed one.
|
|
1950
|
+
it('falls back when the persisted accent has been retired', () => {
|
|
1951
|
+
expect(chooseAccent('sunset-tangerine', valid, valid[0])).toBe('axi-gold')
|
|
1952
|
+
})
|
|
1953
|
+
|
|
1954
|
+
it('falls back when nothing was ever persisted', () => {
|
|
1955
|
+
expect(chooseAccent(null, valid, valid[0])).toBe('axi-gold')
|
|
1956
|
+
})
|
|
1957
|
+
})
|
|
1958
|
+
```
|
|
1959
|
+
|
|
1960
|
+
Append to `tests/site.test.mjs`:
|
|
1961
|
+
|
|
1962
|
+
```js
|
|
1963
|
+
describe('landing and gallery', () => {
|
|
1964
|
+
it('writes the landing page at the site root', () => {
|
|
1965
|
+
expect(written).toContain('index.html')
|
|
1966
|
+
})
|
|
1967
|
+
|
|
1968
|
+
it('shows the install snippet and routes onward', () => {
|
|
1969
|
+
const html = read('index.html')
|
|
1970
|
+
expect(html).toContain('@axiapps/axi-design')
|
|
1971
|
+
expect(html).toContain(`href="${'/axi-design/start/'}"`)
|
|
1972
|
+
expect(html).toContain(`href="${'/axi-design/components/'}"`)
|
|
1973
|
+
})
|
|
1974
|
+
|
|
1975
|
+
it('writes the gallery and its script', () => {
|
|
1976
|
+
expect(written).toContain('gallery/index.html')
|
|
1977
|
+
expect(written).toContain('gallery.js')
|
|
1978
|
+
})
|
|
1979
|
+
})
|
|
1980
|
+
```
|
|
1981
|
+
|
|
1982
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1983
|
+
|
|
1984
|
+
Run: `npx vitest run tests/accent.test.mjs tests/site.test.mjs`
|
|
1985
|
+
Expected: FAIL — `chooseAccent` import resolves (Task 6 wrote it) but the landing assertions fail on missing `index.html`.
|
|
1986
|
+
|
|
1987
|
+
- [ ] **Step 3: Write landing.mjs**
|
|
1988
|
+
|
|
1989
|
+
Create `docs/site/landing.mjs` exporting `landing()`, built with `page({ title: 'axi-design', nav: '', … })` and no sidebar or TOC. Body, in order:
|
|
1990
|
+
|
|
1991
|
+
1. A hero `<div class="axi-panel">` containing real components — a `.axi-card axi-card--strip`, a `.axi-meter-list` of three rows, a `.axi-row` of `.axi-chip` variants, a `.axi-stat` — so the first thing on screen is the language rather than a description of it.
|
|
1992
|
+
2. `<h1 class="docs-title">` with the tagline: flat and outlined, dark, drawn in saturated ink.
|
|
1993
|
+
3. The three-line install, through `example()` from `render.mjs` so it gets the same highlighting and copy button as every other code block on the site.
|
|
1994
|
+
4. A `.axi-row` of two buttons: `.axi-btn axi-btn--primary` to `url('start/')` and `.axi-btn` to `url('components/')`.
|
|
1995
|
+
5. One paragraph on the philosophy, ending in a link to `url('rules/')`.
|
|
1996
|
+
|
|
1997
|
+
- [ ] **Step 4: Split the accent switcher out of gallery.js**
|
|
1998
|
+
|
|
1999
|
+
In `gallery.js`, delete the accent `<select>` listener — `docs/site/accent.js` owns it for every page now, including the gallery. Leave the menu, drawer and tooltip behaviour alone.
|
|
2000
|
+
|
|
2001
|
+
- [ ] **Step 5: Convert index.html into a gallery fragment**
|
|
2002
|
+
|
|
2003
|
+
`index.html` keeps its `<section class="g-section">` blocks and drops its own `<!doctype>`, `<head>`, `.axi-mast` header and `<script>` tags — the shell supplies all four now. Move its `.g-section` rules into `docs/site/docs.css`, renamed `.docs-section`.
|
|
2004
|
+
|
|
2005
|
+
Simplest mechanical route: extract everything between `<main class="axi-page">` and its closing `</main>`, plus the trailing `.axi-scrim` and `.axi-drawer` elements, into `docs/pages/gallery.html`, and delete `index.html`.
|
|
2006
|
+
|
|
2007
|
+
- [ ] **Step 6: Emit both from site.mjs**
|
|
2008
|
+
|
|
2009
|
+
```js
|
|
2010
|
+
write('index.html', landing())
|
|
2011
|
+
write('gallery/index.html', page({
|
|
2012
|
+
title: 'Gallery',
|
|
2013
|
+
nav: 'gallery/',
|
|
2014
|
+
body: readFileSync(resolve(ROOT, 'docs/pages/gallery.html'), 'utf8'),
|
|
2015
|
+
}))
|
|
2016
|
+
copy('gallery.js', 'gallery.js')
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
and add `<script type="module" src="${url('gallery.js')}"></script>` to the gallery page only — extend `page()` with an optional `scripts = []` parameter rather than loading it on all thirty-odd component pages.
|
|
2020
|
+
|
|
2021
|
+
- [ ] **Step 7: Move the stale gallery assertion**
|
|
2022
|
+
|
|
2023
|
+
`tests/accents.test.mjs`'s `describe('gallery accent switcher')` reads `index.html` for a hard-coded `<option>` list that no longer exists. Replace that block with an assertion against the generated shell:
|
|
2024
|
+
|
|
2025
|
+
```js
|
|
2026
|
+
describe('the accent switcher', () => {
|
|
2027
|
+
it('offers exactly the official accents, in order', () => {
|
|
2028
|
+
const html = page({ title: 'x', nav: '', body: '' })
|
|
2029
|
+
const select = html.match(/<select[^>]*id="accent"[\s\S]*?<\/select>/)[0]
|
|
2030
|
+
const options = [...select.matchAll(/<option value="([a-z-]+)">([^<]+)<\/option>/g)]
|
|
2031
|
+
.map((m) => ({ id: m[1], label: m[2] }))
|
|
2032
|
+
expect(options).toEqual(ACCENTS.map((a) => ({ id: a.id, label: a.label })))
|
|
2033
|
+
})
|
|
2034
|
+
})
|
|
2035
|
+
```
|
|
2036
|
+
|
|
2037
|
+
with `page` imported from `../docs/site/shell.mjs`.
|
|
2038
|
+
|
|
2039
|
+
- [ ] **Step 8: Run the suite and look at the site**
|
|
2040
|
+
|
|
2041
|
+
Run: `npm test && npm run docs`
|
|
2042
|
+
Expected: PASS. Then serve it and click through `/`, `/components/`, `/components/meter/`, `/rules/`, `/gallery/`, and switch the accent on each.
|
|
2043
|
+
|
|
2044
|
+
- [ ] **Step 9: Commit**
|
|
2045
|
+
|
|
2046
|
+
```bash
|
|
2047
|
+
git add -A
|
|
2048
|
+
git commit -m "$(cat <<'EOF'
|
|
2049
|
+
feat(docs): a landing page that shows the language, and the gallery keeps its job
|
|
2050
|
+
|
|
2051
|
+
The front door has about six seconds and one job: show, not tell. Real
|
|
2052
|
+
components in the hero, the three-line install under them, and two ways
|
|
2053
|
+
onward - Start and Components.
|
|
2054
|
+
|
|
2055
|
+
The old single-page gallery becomes /gallery/ rather than disappearing. It is
|
|
2056
|
+
the only view in which the system can be judged as a whole, which thirty
|
|
2057
|
+
separate component pages actively obscure, and losing it would cost something
|
|
2058
|
+
no per-component page gives back.
|
|
2059
|
+
|
|
2060
|
+
gallery.js gives up the accent switcher - accent.js owns it on every page now -
|
|
2061
|
+
and keeps the menu, drawer and tooltip behaviour that only the gallery has.
|
|
2062
|
+
|
|
2063
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
2064
|
+
EOF
|
|
2065
|
+
)"
|
|
2066
|
+
```
|
|
2067
|
+
|
|
2068
|
+
---
|
|
2069
|
+
|
|
2070
|
+
### Task 10: Search and llms.txt
|
|
2071
|
+
|
|
2072
|
+
The last two audiences: the author who wants a component in two keystrokes, and the agent that needs everything at once.
|
|
2073
|
+
|
|
2074
|
+
**Files:**
|
|
2075
|
+
- Modify: `docs/site/search.js` (written empty in Task 7), `scripts/site.mjs`
|
|
2076
|
+
- Create: `docs/site/llms.mjs`
|
|
2077
|
+
- Test: `tests/site.test.mjs`, `tests/search.test.mjs`
|
|
2078
|
+
|
|
2079
|
+
**Interfaces:**
|
|
2080
|
+
- Consumes: `entries` (Task 2/3); `KNOBS` (Task 4).
|
|
2081
|
+
- Produces:
|
|
2082
|
+
- `matches(query, index): entry[]` — exported from `docs/site/search.js` so it is testable in node.
|
|
2083
|
+
- `llmsTxt(): string` — exported from `docs/site/llms.mjs`.
|
|
2084
|
+
- The search index itself is built inline in `scripts/site.mjs` (five fields projected off `entries()`); it needs no module of its own.
|
|
2085
|
+
|
|
2086
|
+
- [ ] **Step 1: Write the failing tests**
|
|
2087
|
+
|
|
2088
|
+
Create `tests/search.test.mjs`:
|
|
2089
|
+
|
|
2090
|
+
```js
|
|
2091
|
+
import { describe, it, expect } from 'vitest'
|
|
2092
|
+
import { matches } from '../docs/site/search.js'
|
|
2093
|
+
|
|
2094
|
+
const index = [
|
|
2095
|
+
{ id: 'meter', name: 'Meter', layer: 'data', summary: 'A horizontal bar.', classes: ['.axi-meter', '.axi-meter__fill'] },
|
|
2096
|
+
{ id: 'btn', name: 'Button', layer: 'primitives', summary: 'A control.', classes: ['.axi-btn'] },
|
|
2097
|
+
]
|
|
2098
|
+
|
|
2099
|
+
describe('matches', () => {
|
|
2100
|
+
it('finds by name, case-insensitively', () => {
|
|
2101
|
+
expect(matches('MET', index).map((e) => e.id)).toEqual(['meter'])
|
|
2102
|
+
})
|
|
2103
|
+
|
|
2104
|
+
it('finds by class name, with or without the leading dot', () => {
|
|
2105
|
+
expect(matches('.axi-btn', index).map((e) => e.id)).toEqual(['btn'])
|
|
2106
|
+
expect(matches('axi-meter__fill', index).map((e) => e.id)).toEqual(['meter'])
|
|
2107
|
+
})
|
|
2108
|
+
|
|
2109
|
+
it('finds by summary text', () => {
|
|
2110
|
+
expect(matches('horizontal', index).map((e) => e.id)).toEqual(['meter'])
|
|
2111
|
+
})
|
|
2112
|
+
|
|
2113
|
+
it('ranks a name match above a summary match', () => {
|
|
2114
|
+
const idx = [{ id: 'a', name: 'Alpha', layer: 'data', summary: 'mentions button', classes: [] },
|
|
2115
|
+
{ id: 'btn', name: 'Button', layer: 'primitives', summary: '', classes: [] }]
|
|
2116
|
+
expect(matches('button', idx)[0].id).toBe('btn')
|
|
2117
|
+
})
|
|
2118
|
+
|
|
2119
|
+
it('returns nothing for an empty query', () => {
|
|
2120
|
+
expect(matches(' ', index)).toEqual([])
|
|
2121
|
+
})
|
|
2122
|
+
})
|
|
2123
|
+
```
|
|
2124
|
+
|
|
2125
|
+
Append to `tests/site.test.mjs`:
|
|
2126
|
+
|
|
2127
|
+
```js
|
|
2128
|
+
describe('machine-readable output', () => {
|
|
2129
|
+
it('writes the search index with an entry per component', () => {
|
|
2130
|
+
expect(written).toContain('search.json')
|
|
2131
|
+
const index = JSON.parse(read('search.json'))
|
|
2132
|
+
expect(index.length).toBe(entries().length)
|
|
2133
|
+
expect(index[0]).toHaveProperty('classes')
|
|
2134
|
+
})
|
|
2135
|
+
|
|
2136
|
+
it('writes llms.txt naming every component, class and knob', () => {
|
|
2137
|
+
expect(written).toContain('llms.txt')
|
|
2138
|
+
const txt = read('llms.txt')
|
|
2139
|
+
for (const e of entries()) {
|
|
2140
|
+
expect(txt).toContain(e.name)
|
|
2141
|
+
for (const cls of e.classes) expect(txt).toContain(cls)
|
|
2142
|
+
}
|
|
2143
|
+
})
|
|
2144
|
+
})
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
2148
|
+
|
|
2149
|
+
Run: `npx vitest run tests/search.test.mjs tests/site.test.mjs`
|
|
2150
|
+
Expected: FAIL — `matches` is not exported from the empty `search.js`; `search.json` and `llms.txt` are not written.
|
|
2151
|
+
|
|
2152
|
+
- [ ] **Step 3: Write search.js**
|
|
2153
|
+
|
|
2154
|
+
Replace `docs/site/search.js`:
|
|
2155
|
+
|
|
2156
|
+
```js
|
|
2157
|
+
// Ranked: a name match beats a class match beats a summary match. The index is
|
|
2158
|
+
// thirty-odd entries, so there is no call for anything cleverer than a scan -
|
|
2159
|
+
// and a dependency-free scan is one less thing between a keystroke and a result.
|
|
2160
|
+
export function matches(query, index) {
|
|
2161
|
+
const q = query.trim().toLowerCase().replace(/^\./, '')
|
|
2162
|
+
if (!q) return []
|
|
2163
|
+
const scored = []
|
|
2164
|
+
for (const entry of index) {
|
|
2165
|
+
const name = entry.name.toLowerCase()
|
|
2166
|
+
const cls = entry.classes.join(' ').toLowerCase()
|
|
2167
|
+
let score = 0
|
|
2168
|
+
if (name.startsWith(q)) score = 3
|
|
2169
|
+
else if (name.includes(q)) score = 2
|
|
2170
|
+
else if (cls.includes(q)) score = 1.5
|
|
2171
|
+
else if (entry.summary.toLowerCase().includes(q)) score = 1
|
|
2172
|
+
if (score) scored.push({ entry, score })
|
|
2173
|
+
}
|
|
2174
|
+
return scored.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
|
2175
|
+
.map((s) => s.entry)
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
if (typeof document !== 'undefined') {
|
|
2179
|
+
const input = document.getElementById('q')
|
|
2180
|
+
const results = document.getElementById('results')
|
|
2181
|
+
if (input && results) {
|
|
2182
|
+
const base = document.documentElement.dataset.base ?? '/'
|
|
2183
|
+
const loaded = fetch(`${base}search.json`).then((r) => r.json())
|
|
2184
|
+
input.addEventListener('input', async () => {
|
|
2185
|
+
const found = matches(input.value, await loaded).slice(0, 8)
|
|
2186
|
+
results.innerHTML = found.map((e) =>
|
|
2187
|
+
`<a href="${base}components/${e.id}/"><strong>${e.name}</strong><small>${e.layer}</small></a>`).join('')
|
|
2188
|
+
results.hidden = found.length === 0
|
|
2189
|
+
})
|
|
2190
|
+
input.addEventListener('blur', () => setTimeout(() => { results.hidden = true }, 150))
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
```
|
|
2194
|
+
|
|
2195
|
+
The client reads the base path from `document.documentElement.dataset.base`, so add `data-base="${BASE}"` to the `<html>` tag in `shell.mjs`'s `page()` — the one place a script may learn the base without hard-coding it.
|
|
2196
|
+
|
|
2197
|
+
- [ ] **Step 4: Write llms.mjs**
|
|
2198
|
+
|
|
2199
|
+
Create `docs/site/llms.mjs` exporting `llmsTxt()`: a plain-text reference beginning with what axi is and the one-line install, then the complete rule list from RULES.md's headings, then — per component, grouped by layer — the name, id, URL, summary, every class, every knob with its fallback, and every example's markup verbatim. It closes with the full knob table. Written for an agent, so no ceremony: no navigation, no repetition, everything on the page.
|
|
2200
|
+
|
|
2201
|
+
- [ ] **Step 5: Emit both from site.mjs**
|
|
2202
|
+
|
|
2203
|
+
```js
|
|
2204
|
+
write('search.json', JSON.stringify(entries().map(({ id, name, layer, summary, classes }) =>
|
|
2205
|
+
({ id, name, layer, summary, classes }))))
|
|
2206
|
+
write('llms.txt', llmsTxt())
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
- [ ] **Step 6: Run the suite**
|
|
2210
|
+
|
|
2211
|
+
Run: `npm test`
|
|
2212
|
+
Expected: PASS.
|
|
2213
|
+
|
|
2214
|
+
- [ ] **Step 7: Commit**
|
|
2215
|
+
|
|
2216
|
+
```bash
|
|
2217
|
+
git add docs/site/search.js docs/site/llms.mjs scripts/site.mjs tests/search.test.mjs tests/site.test.mjs
|
|
2218
|
+
git commit -m "$(cat <<'EOF'
|
|
2219
|
+
feat(docs): search for the author, llms.txt for the agent
|
|
2220
|
+
|
|
2221
|
+
Both fall out of the manifest, which is the argument for having built one. The
|
|
2222
|
+
search index is the same thirty entries with the prose dropped; the scan is
|
|
2223
|
+
ranked name over class over summary and takes no dependency, because at this
|
|
2224
|
+
size anything cleverer is a dependency between a keystroke and a result.
|
|
2225
|
+
|
|
2226
|
+
llms.txt is the whole reference on one page with no navigation and no
|
|
2227
|
+
repetition: every component, every class, every knob and its fallback, every
|
|
2228
|
+
example verbatim. Written for the reader that wants all of it at once.
|
|
2229
|
+
|
|
2230
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
2231
|
+
EOF
|
|
2232
|
+
)"
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
---
|
|
2236
|
+
|
|
2237
|
+
### Task 11: Local preview, deploy, and packaging
|
|
2238
|
+
|
|
2239
|
+
Ship it, and close the packaging regression this project introduced.
|
|
2240
|
+
|
|
2241
|
+
**Files:**
|
|
2242
|
+
- Create: `scripts/serve.mjs`
|
|
2243
|
+
- Modify: `.github/workflows/pages.yml`, `package.json` (`files`, `serve` script), `README.md` (point at the site), `tests/accents.test.mjs` (packaging assertions)
|
|
2244
|
+
|
|
2245
|
+
**Interfaces:**
|
|
2246
|
+
- Consumes: `build` from `scripts/site.mjs`.
|
|
2247
|
+
- Produces: `npm run serve`.
|
|
2248
|
+
|
|
2249
|
+
- [ ] **Step 1: Write the failing packaging test**
|
|
2250
|
+
|
|
2251
|
+
`package.json`'s `files` list contains `"docs"`. Before this project that meant one file, `docs/RULES.md`. It now means the manifest, the generator, the guides and the spec directory — all shipped to every consumer, none of any use to them.
|
|
2252
|
+
|
|
2253
|
+
Append to `tests/accents.test.mjs`'s `describe('packaging')`:
|
|
2254
|
+
|
|
2255
|
+
```js
|
|
2256
|
+
it('ships RULES.md and nothing else from docs/', () => {
|
|
2257
|
+
const [pack] = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))
|
|
2258
|
+
const docs = pack.files.map((f) => f.path).filter((p) => p.startsWith('docs/'))
|
|
2259
|
+
expect(docs).toEqual(['docs/RULES.md'])
|
|
2260
|
+
})
|
|
2261
|
+
|
|
2262
|
+
it('declares no runtime dependencies', () => {
|
|
2263
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
2264
|
+
expect(pkg.dependencies ?? {}).toEqual({})
|
|
2265
|
+
expect(Object.keys(pkg.devDependencies)).toContain('marked')
|
|
2266
|
+
})
|
|
2267
|
+
```
|
|
2268
|
+
|
|
2269
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
2270
|
+
|
|
2271
|
+
Run: `npx vitest run tests/accents.test.mjs -t packaging`
|
|
2272
|
+
Expected: FAIL — `docs/` contains the manifest, site and spec directories.
|
|
2273
|
+
|
|
2274
|
+
- [ ] **Step 3: Narrow the files list**
|
|
2275
|
+
|
|
2276
|
+
In `package.json`, change `"docs"` to `"docs/RULES.md"` in `files`, and update the adjacent `//files` comment: `docs/RULES.md` rides along because it is the reason any of it is shaped the way it is; the manifest, the generator and the guides build the site and are no use to a consumer.
|
|
2277
|
+
|
|
2278
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
2279
|
+
|
|
2280
|
+
Run: `npx vitest run tests/accents.test.mjs -t packaging`
|
|
2281
|
+
Expected: PASS.
|
|
2282
|
+
|
|
2283
|
+
- [ ] **Step 5: Write serve.mjs**
|
|
2284
|
+
|
|
2285
|
+
Create `scripts/serve.mjs`:
|
|
2286
|
+
|
|
2287
|
+
```js
|
|
2288
|
+
import { createServer } from 'node:http'
|
|
2289
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2290
|
+
import { resolve, extname, dirname } from 'node:path'
|
|
2291
|
+
import { fileURLToPath } from 'node:url'
|
|
2292
|
+
|
|
2293
|
+
// Local preview serves from / while Pages serves from /axi-design/, so the
|
|
2294
|
+
// site is rebuilt here with AXI_BASE set to the root. Building rather than
|
|
2295
|
+
// serving _site as-is is deliberate: a preview of a stale build is worse than
|
|
2296
|
+
// no preview, because it looks like a preview.
|
|
2297
|
+
process.env.AXI_BASE = '/'
|
|
2298
|
+
const { build } = await import('./site.mjs')
|
|
2299
|
+
|
|
2300
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
2301
|
+
const OUT = resolve(ROOT, '_site')
|
|
2302
|
+
build(OUT)
|
|
2303
|
+
|
|
2304
|
+
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
2305
|
+
'.json': 'application/json', '.txt': 'text/plain', '.svg': 'image/svg+xml' }
|
|
2306
|
+
|
|
2307
|
+
createServer((req, res) => {
|
|
2308
|
+
let path = resolve(OUT, decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, ''))
|
|
2309
|
+
if (!path.startsWith(OUT)) { res.writeHead(403).end(); return }
|
|
2310
|
+
if (existsSync(path) && statSync(path).isDirectory()) path = resolve(path, 'index.html')
|
|
2311
|
+
if (!existsSync(path)) { res.writeHead(404).end('not found'); return }
|
|
2312
|
+
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' })
|
|
2313
|
+
res.end(readFileSync(path))
|
|
2314
|
+
}).listen(4173, () => console.log('axi-design docs on http://localhost:4173'))
|
|
2315
|
+
```
|
|
2316
|
+
|
|
2317
|
+
Add to `package.json` scripts: `"serve": "node scripts/serve.mjs"`.
|
|
2318
|
+
|
|
2319
|
+
- [ ] **Step 6: Update the Pages workflow**
|
|
2320
|
+
|
|
2321
|
+
In `.github/workflows/pages.yml`, replace the staging step's first two lines:
|
|
2322
|
+
|
|
2323
|
+
```yaml
|
|
2324
|
+
- name: Build the site and stage every published version
|
|
2325
|
+
run: |
|
|
2326
|
+
npm run docs
|
|
2327
|
+
for tag in $(git tag -l 'v*' | sort -V); do
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
Delete `mkdir -p _site` and the `cp -r index.html gallery.js dist _site/` line — `npm run docs` creates `_site` and writes everything into it. Leave the per-tag loop, the `ls -R _site`, and every step after it exactly as they are: that loop is the promise that `/v1/axi.css` keeps resolving, and it reads from git tags, not from the site build.
|
|
2331
|
+
|
|
2332
|
+
- [ ] **Step 7: Verify the deploy staging locally**
|
|
2333
|
+
|
|
2334
|
+
Run:
|
|
2335
|
+
|
|
2336
|
+
```bash
|
|
2337
|
+
rm -rf _site && npm run docs && for tag in $(git tag -l 'v*' | sort -V); do
|
|
2338
|
+
major="${tag%%.*}"; mkdir -p "_site/$major"; git show "$tag:dist/axi.css" > "_site/$major/axi.css"; done
|
|
2339
|
+
ls _site && ls _site/v1
|
|
2340
|
+
```
|
|
2341
|
+
|
|
2342
|
+
Expected: `_site` holds `index.html`, `components/`, `start/`, `rules/`, `theming/`, `gallery/`, `llms.txt`, `search.json`, the stylesheets — and `_site/v1/axi.css` exists.
|
|
2343
|
+
|
|
2344
|
+
- [ ] **Step 8: Update the README**
|
|
2345
|
+
|
|
2346
|
+
In `README.md`, change "See [the pattern gallery](…) for every component, with a live accent switcher" to point at the documentation site, and mention that `/gallery/` still holds the everything-at-once view. Leave the install instructions and the generated knob table alone.
|
|
2347
|
+
|
|
2348
|
+
- [ ] **Step 9: Run everything**
|
|
2349
|
+
|
|
2350
|
+
Run: `npm run build && npm run docs && npm test`
|
|
2351
|
+
Expected: PASS, and `git diff --stat dist/` shows **no change** — the shipped artifact is byte-identical to where this project started.
|
|
2352
|
+
|
|
2353
|
+
- [ ] **Step 10: Commit**
|
|
2354
|
+
|
|
2355
|
+
```bash
|
|
2356
|
+
git add -A
|
|
2357
|
+
git commit -m "$(cat <<'EOF'
|
|
2358
|
+
ci+pkg: deploy the site, preview it locally, and stop shipping docs/ wholesale
|
|
2359
|
+
|
|
2360
|
+
pages.yml builds the site instead of copying one page into place. The per-tag
|
|
2361
|
+
staging loop is untouched: every published /vN/axi.css is still rebuilt from
|
|
2362
|
+
its own tag on every deploy, which is the one promise consumers actually rely
|
|
2363
|
+
on.
|
|
2364
|
+
|
|
2365
|
+
`npm run serve` rebuilds with AXI_BASE=/ and serves on 4173. Rebuilding rather
|
|
2366
|
+
than serving _site as it stands is deliberate - a stale preview is worse than
|
|
2367
|
+
none, because it still looks like a preview.
|
|
2368
|
+
|
|
2369
|
+
The files allowlist said "docs", which meant one file until this project gave
|
|
2370
|
+
docs/ a manifest, a generator, guides and a spec directory. It now says
|
|
2371
|
+
docs/RULES.md, and a test pins it so the tarball cannot quietly grow again.
|
|
2372
|
+
|
|
2373
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
2374
|
+
EOF
|
|
2375
|
+
)"
|
|
2376
|
+
```
|