@erclx/canon 4.90.0 → 4.93.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.
Files changed (63) hide show
  1. package/README.md +5 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/design-taste/REQUIREMENT.md +78 -0
  4. package/claude/skills/design-taste/SKILL.md +148 -0
  5. package/claude/skills/design-taste/references/craft.md +58 -0
  6. package/claude/skills/design-taste/references/kinds.md +52 -0
  7. package/claude/skills/design-taste/references/preflight.md +77 -0
  8. package/claude/skills/design-taste/references/systems.md +49 -0
  9. package/claude/skills/design-taste/references/tells.md +101 -0
  10. package/claude/skills/design-taste/references/vocabulary.md +62 -0
  11. package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -2
  12. package/claude/skills/draft-and-pick/SKILL.md +13 -5
  13. package/claude/skills/draft-ready/REQUIREMENT.md +53 -0
  14. package/claude/skills/draft-ready/SKILL.md +118 -0
  15. package/claude/skills/draft-ready/references/assembly.md +67 -0
  16. package/claude/skills/session-compact/REQUIREMENT.md +44 -0
  17. package/claude/skills/session-compact/SKILL.md +60 -0
  18. package/claude/skills/session-compact/references/handoff-note.md +68 -0
  19. package/claude/skills/session-map/REQUIREMENT.md +3 -2
  20. package/claude/skills/session-map/SKILL.md +2 -2
  21. package/claude/skills/session-resume/SKILL.md +3 -3
  22. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  23. package/docs/agents/capture.md +12 -5
  24. package/docs/agents/commands.md +3 -3
  25. package/docs/agents/rule-citations.md +1 -1
  26. package/docs/agents/teach.md +2 -2
  27. package/docs/workflow/ai-workflow.md +3 -1
  28. package/governance/rules/claude/563-ready.md +4 -0
  29. package/governance/rules/lib/305-e2e-reliability.md +3 -2
  30. package/governance/rules/lib/306-test-scope.md +1 -2
  31. package/governance/rules/ui/410-a11y.md +9 -0
  32. package/governance/rules/ui/420-forms.md +9 -0
  33. package/governance/rules/ui/460-design-taste.md +29 -0
  34. package/governance/rules/ui/470-motion.md +23 -0
  35. package/governance/stacks/astro.toml +1 -1
  36. package/governance/stacks/react.toml +1 -1
  37. package/package.json +3 -2
  38. package/scripts/core/regen-hero.sh +4 -3
  39. package/src/capture/render.ts +15 -1
  40. package/src/claude/cases/authoring.ts +5 -0
  41. package/src/claude/cases/misc.ts +10 -0
  42. package/src/cli.ts +1 -1
  43. package/src/commands/capture.ts +26 -1
  44. package/src/commands/design.ts +5 -0
  45. package/src/commands/feedback.ts +1 -0
  46. package/src/commands/serve.ts +11 -1
  47. package/src/commands/slides.ts +3 -0
  48. package/src/commands/transcripts.ts +1 -0
  49. package/src/design/base.css +25 -19
  50. package/src/design/css.ts +4 -0
  51. package/src/design/fonts.ts +17 -0
  52. package/src/design/tokens.ts +45 -30
  53. package/src/gate/measures.ts +5 -4
  54. package/src/gate/stages.ts +1 -1
  55. package/src/serve/static.ts +90 -6
  56. package/src/teach/render.ts +2 -2
  57. package/src/teach/workspace.ts +1 -0
  58. package/standards/ready.md +2 -0
  59. package/standards/session.md +1 -0
  60. package/tooling/astro/configs/playwright.config.ts +2 -0
  61. package/tooling/nextjs/configs/playwright.config.ts +2 -0
  62. package/tooling/vite-react/configs/playwright.config.ts +2 -0
  63. package/src/teach/render-fixture.tsx +0 -95
@@ -39,6 +39,12 @@ export interface TypeToken {
39
39
  readonly verify?: readonly ('family' | 'weight' | 'size' | 'lineHeight')[]
40
40
  }
41
41
 
42
+ /** One size the system offers, named apart from any role that takes it. */
43
+ export interface TypeStep {
44
+ readonly step: string
45
+ readonly size: string
46
+ }
47
+
42
48
  export interface SpaceToken {
43
49
  readonly step: string
44
50
  readonly multiplier: string
@@ -57,6 +63,7 @@ export interface DesignTokens {
57
63
  readonly personality: string
58
64
  readonly color: readonly ColorToken[]
59
65
  readonly colorNote: string
66
+ readonly typeScale: readonly TypeStep[]
60
67
  readonly typography: readonly TypeToken[]
61
68
  readonly typographyNote: string
62
69
  readonly spacing: readonly SpaceToken[]
@@ -68,20 +75,19 @@ export interface DesignTokens {
68
75
  readonly preamble: string
69
76
  }
70
77
 
71
- /** The monospace stack every surface but the landing page hero declares. */
78
+ /** The monospace stack, taken by code and by the terminal frames a page embeds. */
72
79
  const MONO = 'Noto Sans Mono, DejaVu Sans Mono, monospace'
73
80
 
74
81
  /**
75
- * The proportional stack the landing page hero declares and nothing else does.
76
- * Noto Sans is the proportional sibling of the mono above rather than a face
77
- * picked for taste, so the pairing is one superfamily rather than two systems.
82
+ * The proportional stack every role but `code` declares. Geist is the face the
83
+ * visual-direction track set throughout, embedded through `FONT_FACES`.
78
84
  *
79
85
  * The stack stops at two names and a generic because `src/design/base.css` is
80
86
  * written by `canon design regen` and formatted by prettier, and a declaration
81
87
  * past 80 columns is wrapped by the second and flattened by the first. Every
82
88
  * emitted line stays inside the width so the two writers never disagree.
83
89
  */
84
- const SANS = 'Noto Sans, DejaVu Sans, sans-serif'
90
+ const SANS = 'Geist Variable, DejaVu Sans, sans-serif'
85
91
 
86
92
  const DARK_GROUNDS = ['background', 'surface'] as const
87
93
  const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
@@ -98,9 +104,9 @@ export const TOKENS: DesignTokens = {
98
104
  ].join('\n'),
99
105
 
100
106
  personality: [
101
- 'Warm neutrals carry the frame under a single rust accent, rendered in the same monospace the terminal uses. The subject picks the register rather than taste: a toolkit whose primary surface is a shell has no proportional voice available, so the rendered surfaces match the terminal instead of the reverse. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
107
+ 'Warm neutrals carry the frame under a single rust accent, set in Geist. The subject is a toolkit for people who read documents and diffs as much as they run commands, so the voice is a proportional one and the page reads as prose. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
102
108
  '',
103
- 'One surface is carved out of the sentence above, and it is the landing page hero. A rendered surface here shows a reader what the terminal did, so matching the terminal is what makes it legible. A public page addresses somebody who has never opened the terminal at all, and the shell has no voice available for that, which is the reverse of the case the rule was written for. The `page-display` role below is the whole of the carve-out. Every other role on that page stays monospace, including its body, its controls, and every frame it embeds, so the page reads as two families rather than as a second design system.',
109
+ 'Monospace is a role the page uses rather than its voice. The `code` role takes it, and so does any terminal frame a surface embeds, since those show what the shell printed and matching the shell is what makes them legible. Every other role is Geist.',
104
110
  ].join('\n'),
105
111
 
106
112
  colorNote: [
@@ -236,52 +242,62 @@ export const TOKENS: DesignTokens = {
236
242
  ],
237
243
 
238
244
  typographyNote: [
239
- 'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
245
+ 'Seven sizes are on offer, named `t0` through `t6` and emitted as custom properties beside the roles. They are 3.175, 2.375, 1.375, 1.125, 0.9375, 0.8125 and 0.6875 rem, which paint at 50.8, 38, 22, 18, 15, 13 and 11 pixels, and each role takes one. A step answers what sizes exist and a role answers what a stylesheet asks for, so the two stay separate: a role whose step did not move renders as it did, and a role that moved is the only thing that changes what a surface paints. Two steps, `t3` at 18 pixels and `t6` at 11, are offered with no role pointing at them yet.',
240
246
  '',
241
- 'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches, since a hero headline set at the display cap reads as an opening rather than as a section heading.',
247
+ 'Every role but `code` is Geist and `code` is monospace. The hero headline takes the largest step at 50.8 pixels, which no other role reaches, since a headline set at the display cap reads as an opening rather than as a section heading.',
242
248
  '',
243
249
  'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
244
250
  '',
245
251
  'Two rules set tracking and no others touch it. The label role carries `0.05em`, and the display role tightens to `-0.01em`.',
246
252
  ].join('\n'),
247
253
 
254
+ typeScale: [
255
+ { step: 't0', size: '3.175rem' },
256
+ { step: 't1', size: '2.375rem' },
257
+ { step: 't2', size: '1.375rem' },
258
+ { step: 't3', size: '1.125rem' },
259
+ { step: 't4', size: '0.9375rem' },
260
+ { step: 't5', size: '0.8125rem' },
261
+ { step: 't6', size: '0.6875rem' },
262
+ ],
263
+
248
264
  typography: [
249
265
  {
250
266
  role: 'display',
251
- family: MONO,
267
+ family: SANS,
252
268
  weight: '700',
253
- size: '34px',
269
+ size: '2.375rem',
254
270
  lineHeight: '1.3',
255
271
  },
256
272
  {
257
273
  role: 'page-display',
258
274
  family: SANS,
259
275
  weight: '700',
260
- size: '52px',
276
+ size: '3.175rem',
261
277
  lineHeight: '1.1',
262
- verify: ['family', 'size', 'lineHeight'],
278
+ verify: ['size', 'lineHeight'],
263
279
  },
264
280
  {
265
281
  role: 'heading',
266
- family: MONO,
282
+ family: SANS,
267
283
  weight: '700',
268
- size: '19px',
284
+ size: '1.375rem',
269
285
  lineHeight: '1.3',
270
286
  verify: ['lineHeight'],
271
287
  },
272
288
  {
273
289
  role: 'body',
274
- family: MONO,
290
+ family: SANS,
275
291
  weight: '400',
276
- size: '16px',
292
+ size: '0.9375rem',
277
293
  lineHeight: '1.65',
278
294
  verify: ['weight'],
279
295
  },
280
296
  {
281
297
  role: 'label',
282
- family: MONO,
298
+ family: SANS,
283
299
  weight: '400',
284
- size: '12px',
300
+ size: '0.8125rem',
285
301
  lineHeight: '1.45',
286
302
  verify: ['weight', 'lineHeight'],
287
303
  },
@@ -289,27 +305,26 @@ export const TOKENS: DesignTokens = {
289
305
  role: 'code',
290
306
  family: MONO,
291
307
  weight: '700',
292
- size: '14.5px',
308
+ size: '0.8125rem',
293
309
  lineHeight: '1.3',
294
310
  verify: ['lineHeight'],
295
311
  },
296
312
  ],
297
313
 
298
314
  spacingNote: [
299
- 'The base is six pixels, which is the largest unit dividing the values that recur: 6, 12, 18, 24, and 30. One-off paddings at 9, 10, 11, 13, 14, 16, 22, 26, 34, and 40 pixels sit off the scale entirely and get no step.',
315
+ 'Seven steps run from a quarter rem to six, at 4, 8, 14, 24, 40, 64 and 96 pixels on a 16 pixel root. The scale is not a grid of one base, since the 14 between the 8 and the 24 is the step a control needs and no multiple of four supplies it, so the multiplier column counts quarter rems rather than claiming a unit.',
300
316
  '',
301
- 'The outer window padding is a single declaration reading `44px 52px 38px`, and none of its three values divides by six. They carry no multiplier for that reason, and one declaration setting all three is the only thing grouping them, so they are a frame register rather than a scale.',
317
+ 'The outer window padding of the capture frames, which the previous record carried as a three-value frame register, is not part of the scale. Those frames set their own padding in the template and read no step, so the register is retired here rather than mapped onto steps no frame uses.',
302
318
  ].join('\n'),
303
319
 
304
320
  spacing: [
305
- { step: 'xs', multiplier: '1', value: '6px' },
306
- { step: 'sm', multiplier: '2', value: '12px' },
307
- { step: 'md', multiplier: '3', value: '18px' },
308
- { step: 'lg', multiplier: '4', value: '24px' },
309
- { step: 'xl', multiplier: '5', value: '30px' },
310
- { step: 'frame-top', multiplier: 'none', value: '44px' },
311
- { step: 'frame-inline', multiplier: 'none', value: '52px' },
312
- { step: 'frame-bottom', multiplier: 'none', value: '38px' },
321
+ { step: 'xs', multiplier: '1', value: '0.25rem' },
322
+ { step: 'sm', multiplier: '2', value: '0.5rem' },
323
+ { step: 'md', multiplier: '3.5', value: '0.875rem' },
324
+ { step: 'lg', multiplier: '6', value: '1.5rem' },
325
+ { step: 'xl', multiplier: '10', value: '2.5rem' },
326
+ { step: '2xl', multiplier: '16', value: '4rem' },
327
+ { step: '3xl', multiplier: '24', value: '6rem' },
313
328
  ],
314
329
 
315
330
  bordersNote: [
@@ -142,7 +142,7 @@ export function auditsBaselineRel(root: string): string {
142
142
  }
143
143
 
144
144
  export const CAPTURE_STAMP_FAILURE =
145
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
145
+ 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/evidence and commit each frame with its image and its stamp.'
146
146
 
147
147
  function parseJson(payload: string): unknown {
148
148
  try {
@@ -1118,11 +1118,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
1118
1118
  /**
1119
1119
  * A capture set spans two folders. The markup and the template beside it are
1120
1120
  * the authored half and sit under `assets/captures/`, while the image a
1121
- * document points at and the stamp answering for it stay in `assets/`, which is
1122
- * where `--out assets` sends them.
1121
+ * document points at and the stamp answering for it sit under
1122
+ * `assets/evidence/`, which is where `--out assets/evidence` sends them and where
1123
+ * `canon pr evidence` looks for a changed image.
1123
1124
  */
1124
1125
  const CAPTURE_MARKUP_DIR = 'assets/captures'
1125
- const CAPTURE_OUTPUT_DIR = 'assets'
1126
+ const CAPTURE_OUTPUT_DIR = 'assets/evidence'
1126
1127
 
1127
1128
  /**
1128
1129
  * Every capture under `assets/captures/`, named by the base its three files
@@ -201,7 +201,7 @@ export const STAGES: readonly Stage[] = [
201
201
  kind: 'drift',
202
202
  pathspec: 'assets/captures/*.html',
203
203
  failure:
204
- 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets, and commit each assets/captures/*.html with its .png and .stamp.',
204
+ 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets/evidence, and commit each assets/captures/*.html with its .png and .stamp.',
205
205
  },
206
206
  { kind: 'measure', measure: captureStamps },
207
207
  ],
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync, statSync } from 'node:fs'
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve, sep } from 'node:path'
3
3
 
4
4
  /**
@@ -81,6 +81,66 @@ export type ServeOutcome = ServeStarted | ServeRefused
81
81
  export interface ServeOptions {
82
82
  readonly port?: number
83
83
  readonly entry?: string
84
+ /** Answers a directory request with no index page by listing it. Off by default. */
85
+ readonly index?: boolean
86
+ }
87
+
88
+ function escapeHtml(text: string): string {
89
+ return text
90
+ .replaceAll('&', '&amp;')
91
+ .replaceAll('<', '&lt;')
92
+ .replaceAll('>', '&gt;')
93
+ .replaceAll('"', '&quot;')
94
+ .replaceAll("'", '&#39;')
95
+ }
96
+
97
+ interface ListingEntry {
98
+ readonly name: string
99
+ readonly isDirectory: boolean
100
+ }
101
+
102
+ /**
103
+ * Reads the entries a listing may name. Each one takes the same containment
104
+ * test a request for it would, since a listed name is itself a fact about a
105
+ * place outside the root. Dotfiles are hidden because a listing is for
106
+ * navigating pages, and a hidden file is still reachable by its URL.
107
+ */
108
+ function listEntries(root: string, directory: string): ListingEntry[] {
109
+ return readdirSync(directory)
110
+ .filter((name) => !name.startsWith('.'))
111
+ .filter((name) => !escapesThroughLink(root, join(directory, name)))
112
+ .map((name) => ({
113
+ name,
114
+ isDirectory:
115
+ statSync(join(directory, name), {
116
+ throwIfNoEntry: false,
117
+ })?.isDirectory() ?? false,
118
+ }))
119
+ .sort(
120
+ (a, b) =>
121
+ Number(b.isDirectory) - Number(a.isDirectory) ||
122
+ a.name.localeCompare(b.name),
123
+ )
124
+ }
125
+
126
+ function renderListing(
127
+ pathname: string,
128
+ entries: readonly ListingEntry[],
129
+ hasParent: boolean,
130
+ ): string {
131
+ const rows = entries.map(({ name, isDirectory }) => {
132
+ const label = isDirectory ? `${name}/` : name
133
+ return `<li><a href="${escapeHtml(encodeURIComponent(name))}${isDirectory ? '/' : ''}">${escapeHtml(label)}</a></li>`
134
+ })
135
+ if (hasParent) rows.unshift('<li><a href="../">../</a></li>')
136
+ return `<!doctype html>
137
+ <meta charset="utf-8">
138
+ <title>Index of ${escapeHtml(pathname)}</title>
139
+ <h1>Index of ${escapeHtml(pathname)}</h1>
140
+ <ul>
141
+ ${rows.join('\n')}
142
+ </ul>
143
+ `
84
144
  }
85
145
 
86
146
  function refuse(reason: ServeRefusal, detail: string): ServeRefused {
@@ -165,13 +225,14 @@ export function shouldWalkPast(error: unknown): boolean {
165
225
  function listen(
166
226
  root: string,
167
227
  first: number,
228
+ index: boolean,
168
229
  ): { server: ReturnType<typeof Bun.serve>; port: number } | undefined {
169
230
  for (let port = first; port < first + PORT_ATTEMPTS; port++) {
170
231
  try {
171
232
  const server = Bun.serve({
172
233
  hostname: SERVE_HOST,
173
234
  port,
174
- fetch: (request) => respond(root, request),
235
+ fetch: (request) => respond(root, request, index),
175
236
  })
176
237
  /**
177
238
  * The bound port rather than the requested one. Port 0 asks the OS to
@@ -196,6 +257,7 @@ function listen(
196
257
  export async function respond(
197
258
  root: string,
198
259
  request: Request,
260
+ index = false,
199
261
  ): Promise<Response> {
200
262
  const { pathname, search } = new URL(request.url)
201
263
  const forbidden = () =>
@@ -229,7 +291,19 @@ export async function respond(
229
291
  headers: { location: `${pathname}/${search}` },
230
292
  })
231
293
  }
232
- path = join(path, DEFAULT_ENTRY)
294
+ const indexPage = join(path, DEFAULT_ENTRY)
295
+ if (index && !existsSync(indexPage)) {
296
+ return new Response(
297
+ renderListing(pathname, listEntries(root, path), path !== root),
298
+ {
299
+ headers: {
300
+ 'content-type': 'text/html; charset=utf-8',
301
+ 'cache-control': 'no-store',
302
+ },
303
+ },
304
+ )
305
+ }
306
+ path = indexPage
233
307
  }
234
308
 
235
309
  /**
@@ -291,7 +365,7 @@ export function startServer(
291
365
  */
292
366
  let bound: ReturnType<typeof listen>
293
367
  try {
294
- bound = listen(root, first)
368
+ bound = listen(root, first, options.index ?? false)
295
369
  } catch (error) {
296
370
  const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
297
371
  return refuse(
@@ -307,14 +381,24 @@ export function startServer(
307
381
  )
308
382
  }
309
383
 
384
+ /**
385
+ * A listing answers `/` where no index page exists, so under the flag the
386
+ * default entry is the root itself. An explicit `--entry` keeps its own link
387
+ * and its own warning.
388
+ */
389
+ const listsRoot =
390
+ (options.index ?? false) &&
391
+ options.entry === undefined &&
392
+ !existsSync(entryPath)
393
+
310
394
  return {
311
395
  ok: true,
312
396
  root,
313
397
  host: SERVE_HOST,
314
398
  port: bound.port,
315
399
  entry,
316
- url: `http://${SERVE_HOST}:${bound.port}/${entry}`,
317
- entryExists: existsSync(entryPath),
400
+ url: `http://${SERVE_HOST}:${bound.port}/${listsRoot ? '' : entry}`,
401
+ entryExists: listsRoot || existsSync(entryPath),
318
402
  stop: async () => {
319
403
  await bound.server.stop(true)
320
404
  },
@@ -109,8 +109,8 @@ function renderBlock(
109
109
 
110
110
  /**
111
111
  * Renders a lesson body's structural blocks through the same components
112
- * `render-fixture.tsx` composes by hand, so a real lesson and the committed
113
- * fixture share one rendering mechanism. Every block past the first refusal
112
+ * `lesson.test.tsx` composes as JSX, so a real lesson and the component test
113
+ * share one rendering mechanism. Every block past the first refusal
114
114
  * goes unread, matching how a malformed JSON parse refuses the whole call.
115
115
  */
116
116
  export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
@@ -246,6 +246,7 @@ async function listSlugs(dir: string): Promise<string[]> {
246
246
  return entries
247
247
  .filter((entry) => entry.isDirectory())
248
248
  .map((entry) => entry.name)
249
+ .filter((name) => WORKSPACE_NAME.test(name))
249
250
  .sort()
250
251
  }
251
252
 
@@ -11,6 +11,8 @@ The folder is gitignored, and backed wherever a records remote is configured: `c
11
11
 
12
12
  ## Scope
13
13
 
14
+ The `draft-ready` skill owns the procedure that writes a ready folder, and this standard owns the shape it writes to.
15
+
14
16
  Governs a ready folder under `.canon/ready/<nn>-<slug>/`: folder layout, ordinal naming, the overview's frontmatter, what the mirrored tree holds, the thin-plan contract that ships it, and the lifecycle from the live folder to the archive.
15
17
 
16
18
  Does not govern:
@@ -18,6 +18,7 @@ Does not govern:
18
18
  - The task board the file sits beside, its filenames, and its archiving: `tasks.md`
19
19
  - The transform from a branch name to the slug in the filename: `slug.md`
20
20
  - The role-specific sections a caller adds over the core, which belong to that caller
21
+ - The note a plain session writes at `.canon/compact/<slug>.md`, whose shape lives in the `session-compact` skill's own reference
21
22
  - Which memories a session captures before writing, which is the capture surface's own subject
22
23
 
23
24
  ## What a working session map looks like
@@ -13,6 +13,8 @@ export default defineConfig({
13
13
  use: {
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
+ reducedMotion: 'reduce',
16
18
  },
17
19
  projects: [
18
20
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -11,6 +11,8 @@ export default defineConfig({
11
11
  use: {
12
12
  trace: 'on-first-retry',
13
13
  baseURL,
14
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
15
+ reducedMotion: 'reduce',
14
16
  },
15
17
  projects: [
16
18
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -13,6 +13,8 @@ export default defineConfig({
13
13
  use: {
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
+ reducedMotion: 'reduce',
16
18
  },
17
19
  projects: [
18
20
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -1,95 +0,0 @@
1
- /** @jsxImportSource ./html */
2
- import { mkdir, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
5
- import { buildDesignCss } from '@/design/css'
6
- import { TEACH_FONT_FACES } from '@/teach/fonts'
7
- import { Heading } from '@/teach/components/heading'
8
- import { List } from '@/teach/components/list'
9
- import { Paragraph } from '@/teach/components/paragraph'
10
- import { render } from '@/teach/html/jsx-runtime'
11
-
12
- /**
13
- * Regenerates the committed fixture lesson under
14
- * `examples/teach/00-fixture/`, the same shape `examples/slides/showcase.md`
15
- * takes against its own hand-rendered snapshot. Run with
16
- * `bun src/teach/render-fixture.tsx` after a component changes shape.
17
- */
18
-
19
- const FIXTURE_ROOT = join(
20
- import.meta.dir,
21
- '..',
22
- '..',
23
- 'examples',
24
- 'teach',
25
- '00-fixture',
26
- )
27
- const LESSON_TITLE = 'Compass bearings'
28
- const LESSON_FILE = '0001-compass-bearings.html'
29
-
30
- const STEPS = [
31
- 'Point the direction-of-travel arrow at the landmark.',
32
- 'Rotate the bezel until the orienting arrow lines up with the needle.',
33
- 'Read the bearing where the direction-of-travel arrow meets the bezel.',
34
- ]
35
-
36
- const body = (
37
- <>
38
- <Heading level={1}>{LESSON_TITLE}</Heading>
39
- <Paragraph lede>
40
- A bearing is the compass direction from where you stand to whatever you
41
- are aiming at, measured clockwise from north.
42
- </Paragraph>
43
- <Paragraph>
44
- Hold the compass level and let the needle settle before reading anything
45
- off it. A bearing taken while walking or tilted reads confidently and
46
- wrong.
47
- </Paragraph>
48
- <Paragraph>
49
- Three steps turn a sighted landmark into a number you can act on:
50
- </Paragraph>
51
- <List ordered items={STEPS} />
52
- <Paragraph>
53
- The same three steps run in reverse turn a bearing on a map into a
54
- direction to walk, which is the only reason to learn them in this order.
55
- </Paragraph>
56
- </>
57
- )
58
-
59
- function page(title: string, main: string): string {
60
- return `<!doctype html>
61
- <html lang="en">
62
- <head>
63
- <meta charset="utf-8">
64
- <meta name="viewport" content="width=device-width, initial-scale=1">
65
- <title>${title}</title>
66
- <link rel="stylesheet" href="../assets/course.css">
67
- </head>
68
- <body>
69
- <main class="wide-body">
70
- ${main}
71
- </main>
72
- </body>
73
- </html>
74
- `
75
- }
76
-
77
- async function main(): Promise<void> {
78
- await mkdir(join(FIXTURE_ROOT, 'lessons'), { recursive: true })
79
- await mkdir(join(FIXTURE_ROOT, 'assets'), { recursive: true })
80
-
81
- await writeFile(
82
- join(FIXTURE_ROOT, 'assets', 'course.css'),
83
- buildDesignCss(undefined, {
84
- embedFonts: TEACH_FONT_FACES,
85
- components: TEACH_STYLESHEET_COMPONENTS,
86
- }),
87
- )
88
-
89
- await writeFile(
90
- join(FIXTURE_ROOT, 'lessons', LESSON_FILE),
91
- page(LESSON_TITLE, render(body)),
92
- )
93
- }
94
-
95
- await main()