@erclx/canon 4.80.0 → 4.82.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/design-extract/SKILL.md +5 -1
- package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
- package/claude/skills/sketch-design/SKILL.md +92 -0
- package/claude/skills/teach-workspace/SKILL.md +23 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
- package/docs/agents/commands.md +11 -5
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +99 -0
- package/docs/agents/design-board.md +13 -11
- package/docs/agents/index.md +2 -1
- package/docs/agents/teach.md +16 -0
- package/docs/target-projects.md +15 -0
- package/docs/workflow/ai-workflow.md +4 -2
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/governance/rules/claude/545-decisions.md +12 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/seeds.ts +1 -0
- package/src/commands/claude.ts +26 -5
- package/src/commands/context.ts +425 -0
- package/src/commands/design.ts +24 -8
- package/src/commands/teach.ts +118 -0
- package/src/context/classify/extract.ts +450 -0
- package/src/context/classify/ollama.ts +172 -0
- package/src/context/classify/patterns.ts +114 -0
- package/src/context/classify/prompts.ts +73 -0
- package/src/context/classify/run.ts +348 -0
- package/src/context/classify/settings.ts +196 -0
- package/src/context/folders.ts +1 -0
- package/src/design/board.ts +130 -47
- package/src/project-root.ts +16 -0
- package/src/surface-root.ts +1 -0
- package/src/teach/render.ts +126 -0
- package/standards/decisions.md +100 -0
- package/standards/index.md +1 -0
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/CLAUDE.md +1 -0
- package/tooling/claude/seeds/canon/decisions/index.md +8 -0
package/src/design/board.ts
CHANGED
|
@@ -11,13 +11,17 @@ import { join, relative, sep } from 'node:path'
|
|
|
11
11
|
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
12
12
|
import { renderDesignDoc } from '@/design/render'
|
|
13
13
|
import { colorValue } from '@/design/tokens'
|
|
14
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
14
15
|
import { recordDir } from '@/record-root'
|
|
16
|
+
import { surfaceDir } from '@/surface-root'
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The wireframes panel's creation-root spelling, used in its own empty-state
|
|
20
|
+
* message and by this module's tests to seed a fixture. The panel itself
|
|
21
|
+
* resolves its actual read path through `surfaceDir`, which also reads
|
|
22
|
+
* `.claude/wireframes/` for a target that has not run the surface-roots
|
|
23
|
+
* migration, so this constant names where a fresh file lands rather than
|
|
24
|
+
* where every root's corpus necessarily sits.
|
|
21
25
|
*/
|
|
22
26
|
export const WIREFRAME_DIR = join('canon', 'wireframes')
|
|
23
27
|
|
|
@@ -34,26 +38,6 @@ const WEB_DIST_ENTRY = 'index.html'
|
|
|
34
38
|
const WEB_GALLERY_DIST = join('web', 'gallery-dist')
|
|
35
39
|
const WEB_GALLERY_DIST_ENTRY = 'index.html'
|
|
36
40
|
|
|
37
|
-
interface WireframeEntry {
|
|
38
|
-
readonly path: string
|
|
39
|
-
readonly describes: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The six files the wireframes panel renders, each beside the surface it
|
|
44
|
-
* describes. `index.md` at either level is a catalog rather than a wireframe
|
|
45
|
-
* and is excluded, which is why this list holds six rather than the eight
|
|
46
|
-
* files the corpus carries today.
|
|
47
|
-
*/
|
|
48
|
-
const WIREFRAMES: readonly WireframeEntry[] = [
|
|
49
|
-
{ path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
|
|
50
|
-
{ path: 'slides.md', describes: 'The SLIDES.md render' },
|
|
51
|
-
{ path: 'teach/root.md', describes: 'A teach workspace root listing' },
|
|
52
|
-
{ path: 'teach/contents.md', describes: 'A workspace contents page' },
|
|
53
|
-
{ path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
|
|
54
|
-
{ path: 'teach/chrome.md', describes: 'The shared teach chrome' },
|
|
55
|
-
]
|
|
56
|
-
|
|
57
41
|
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
|
|
58
42
|
|
|
59
43
|
export interface BoardPanel {
|
|
@@ -233,7 +217,8 @@ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); bord
|
|
|
233
217
|
<body>
|
|
234
218
|
<header>
|
|
235
219
|
<h1>Design board</h1>
|
|
236
|
-
<p class="meta">Generated by <code>canon design board</code>.
|
|
220
|
+
<p class="meta">Generated by <code>canon design board</code>. Reads from a caller-resolved root, never installed or synced.</p>
|
|
221
|
+
<p class="meta">Drafting a new candidate: <code>canon:draft-and-pick</code>. Auditing a rendered surface: <code>canon:ux-audit</code>.</p>
|
|
237
222
|
</header>
|
|
238
223
|
<nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
|
|
239
224
|
<main>
|
|
@@ -282,7 +267,7 @@ ${body}
|
|
|
282
267
|
}
|
|
283
268
|
|
|
284
269
|
function writeTokensPanel(root: string, outDir: string): void {
|
|
285
|
-
const sourcePath =
|
|
270
|
+
const sourcePath = surfaceDir(root, 'DESIGN.md')
|
|
286
271
|
const dir = join(outDir, 'tokens')
|
|
287
272
|
|
|
288
273
|
if (!existsSync(sourcePath)) {
|
|
@@ -300,18 +285,56 @@ function writeTokensPanel(root: string, outDir: string): void {
|
|
|
300
285
|
renderDesignDoc(sourcePath, dir)
|
|
301
286
|
}
|
|
302
287
|
|
|
288
|
+
interface WireframeFile {
|
|
289
|
+
readonly relPath: string
|
|
290
|
+
readonly text: string
|
|
291
|
+
readonly description: string | undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Every wireframe under whichever root the corpus resolves at, sorted for a
|
|
296
|
+
* stable render order across runs. `surfaceDir` reads `.claude/wireframes/`
|
|
297
|
+
* for a target that has not run the surface-roots migration, the same
|
|
298
|
+
* fallback `recordDir` already gives the surfaces panel's teach half.
|
|
299
|
+
* `index.md` at any depth is a catalog rather than a wireframe and is
|
|
300
|
+
* excluded. A glob emits OS-native separators, and the corpus nests one
|
|
301
|
+
* level deep, so the label always reads with forward slashes regardless of
|
|
302
|
+
* platform.
|
|
303
|
+
*/
|
|
304
|
+
function listWireframes(root: string): readonly WireframeFile[] {
|
|
305
|
+
const dir = surfaceDir(root, 'wireframes')
|
|
306
|
+
if (!existsSync(dir)) return []
|
|
307
|
+
|
|
308
|
+
const glob = new Bun.Glob('**/*.md')
|
|
309
|
+
const relPaths = [...glob.scanSync({ cwd: dir })]
|
|
310
|
+
.filter((relPath) => relPath.split(sep).pop() !== 'index.md')
|
|
311
|
+
.map((relPath) => relPath.split(sep).join('/'))
|
|
312
|
+
.sort()
|
|
313
|
+
|
|
314
|
+
return relPaths.map((relPath) => {
|
|
315
|
+
const text = readFileSync(join(dir, relPath), 'utf8')
|
|
316
|
+
return {
|
|
317
|
+
relPath,
|
|
318
|
+
text,
|
|
319
|
+
description: readField(parseFrontmatter(text), 'description'),
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
303
324
|
function writeWireframesPanel(root: string, outDir: string): void {
|
|
304
325
|
const dir = join(outDir, 'wireframes')
|
|
305
326
|
mkdirSync(dir, { recursive: true })
|
|
306
327
|
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
328
|
+
const files = listWireframes(root)
|
|
329
|
+
const sections =
|
|
330
|
+
files.length === 0
|
|
331
|
+
? `<p class="empty">No wireframe files under ${WIREFRAME_DIR}/.</p>`
|
|
332
|
+
: files
|
|
333
|
+
.map((file) => {
|
|
334
|
+
const description = file.description ?? file.relPath
|
|
335
|
+
return `<h2>${escapeHtml(file.relPath)}</h2>\n<p>${escapeHtml(description)}</p>\n<pre>${escapeHtml(file.text)}</pre>`
|
|
336
|
+
})
|
|
337
|
+
.join('\n')
|
|
315
338
|
|
|
316
339
|
writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
|
|
317
340
|
}
|
|
@@ -322,15 +345,21 @@ function copyBuilt(source: string, dest: string): void {
|
|
|
322
345
|
cpSync(source, dest, { recursive: true })
|
|
323
346
|
}
|
|
324
347
|
|
|
325
|
-
function writeSurfacesPanel(
|
|
348
|
+
function writeSurfacesPanel(
|
|
349
|
+
root: string,
|
|
350
|
+
outDir: string,
|
|
351
|
+
isToolkitCheckout: boolean,
|
|
352
|
+
): void {
|
|
326
353
|
const dir = join(outDir, 'surfaces')
|
|
327
354
|
mkdirSync(dir, { recursive: true })
|
|
328
355
|
|
|
329
356
|
const distSource = join(root, WEB_DIST)
|
|
330
|
-
const landingBody =
|
|
331
|
-
?
|
|
332
|
-
|
|
333
|
-
|
|
357
|
+
const landingBody = !isToolkitCheckout
|
|
358
|
+
? `<p class="empty">The landing page panel only renders in this toolkit's own checkout.</p>`
|
|
359
|
+
: existsSync(join(distSource, WEB_DIST_ENTRY))
|
|
360
|
+
? (copyBuilt(distSource, join(dir, 'landing')),
|
|
361
|
+
'<iframe src="landing/index.html" loading="lazy"></iframe>')
|
|
362
|
+
: `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
|
|
334
363
|
|
|
335
364
|
const teachSource = recordDir(root, 'teach')
|
|
336
365
|
const teachBody = existsSync(join(teachSource, 'index.html'))
|
|
@@ -347,15 +376,21 @@ function writeSurfacesPanel(root: string, outDir: string): void {
|
|
|
347
376
|
)
|
|
348
377
|
}
|
|
349
378
|
|
|
350
|
-
function writeComponentsPanel(
|
|
379
|
+
function writeComponentsPanel(
|
|
380
|
+
root: string,
|
|
381
|
+
outDir: string,
|
|
382
|
+
isToolkitCheckout: boolean,
|
|
383
|
+
): void {
|
|
351
384
|
const dir = join(outDir, 'components')
|
|
352
385
|
mkdirSync(dir, { recursive: true })
|
|
353
386
|
|
|
354
387
|
const gallerySource = join(root, WEB_GALLERY_DIST)
|
|
355
|
-
const body =
|
|
356
|
-
?
|
|
357
|
-
|
|
358
|
-
|
|
388
|
+
const body = !isToolkitCheckout
|
|
389
|
+
? `<p class="empty">The components panel only renders in this toolkit's own checkout.</p>`
|
|
390
|
+
: existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
|
|
391
|
+
? (copyBuilt(gallerySource, join(dir, 'gallery')),
|
|
392
|
+
'<iframe src="gallery/index.html" loading="lazy"></iframe>')
|
|
393
|
+
: `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
|
|
359
394
|
|
|
360
395
|
writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
|
|
361
396
|
}
|
|
@@ -416,13 +451,54 @@ function writeCandidatesPanel(root: string, outDir: string): void {
|
|
|
416
451
|
const sections = found
|
|
417
452
|
.map(
|
|
418
453
|
({ folder, images }) =>
|
|
419
|
-
`<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
|
|
454
|
+
`<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${escapeHtml(`${folder}/${image}`)}" alt="${escapeHtml(image)}">`).join('\n')}`,
|
|
420
455
|
)
|
|
421
456
|
.join('\n')
|
|
422
457
|
|
|
423
458
|
writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
|
|
424
459
|
}
|
|
425
460
|
|
|
461
|
+
function writeReferencesPanel(root: string, outDir: string): void {
|
|
462
|
+
const dir = join(outDir, 'references')
|
|
463
|
+
mkdirSync(dir, { recursive: true })
|
|
464
|
+
|
|
465
|
+
const referencesDir = recordDir(root, 'review', 'references')
|
|
466
|
+
if (!existsSync(referencesDir)) {
|
|
467
|
+
writeFileSync(
|
|
468
|
+
join(dir, 'index.html'),
|
|
469
|
+
panelPage(
|
|
470
|
+
'References',
|
|
471
|
+
`<p class="empty">No ${relative(root, referencesDir)} folder yet.</p>`,
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const images = imagesIn(referencesDir)
|
|
478
|
+
if (images.length === 0) {
|
|
479
|
+
writeFileSync(
|
|
480
|
+
join(dir, 'index.html'),
|
|
481
|
+
panelPage(
|
|
482
|
+
'References',
|
|
483
|
+
`<p class="empty">${relative(root, referencesDir)} exists but carries no image yet.</p>`,
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const image of images) {
|
|
490
|
+
cpSync(join(referencesDir, image), join(dir, image))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const sections = images
|
|
494
|
+
.map(
|
|
495
|
+
(image) => `<img src="${escapeHtml(image)}" alt="${escapeHtml(image)}">`,
|
|
496
|
+
)
|
|
497
|
+
.join('\n')
|
|
498
|
+
|
|
499
|
+
writeFileSync(join(dir, 'index.html'), panelPage('References', sections))
|
|
500
|
+
}
|
|
501
|
+
|
|
426
502
|
/**
|
|
427
503
|
* Generates the board's page set into `outDir`, clearing whatever was there.
|
|
428
504
|
*
|
|
@@ -433,11 +509,16 @@ function writeCandidatesPanel(root: string, outDir: string): void {
|
|
|
433
509
|
* `cwd` is the caller's own working directory, resolved and passed in
|
|
434
510
|
* explicitly rather than read here, so a test can exercise the checkout-
|
|
435
511
|
* mismatch case without touching the process's real cwd.
|
|
512
|
+
*
|
|
513
|
+
* `isToolkitCheckout` gates the surfaces panel's landing-page half and the
|
|
514
|
+
* whole components panel, both of which read this repository's own build
|
|
515
|
+
* output rather than anything a target project produces.
|
|
436
516
|
*/
|
|
437
517
|
export function generateBoard(
|
|
438
518
|
root: string,
|
|
439
519
|
outDir: string,
|
|
440
520
|
cwd: string,
|
|
521
|
+
isToolkitCheckout: boolean,
|
|
441
522
|
): BoardOutcome {
|
|
442
523
|
if (wouldDeleteRoot([root, cwd], outDir)) {
|
|
443
524
|
return {
|
|
@@ -460,13 +541,15 @@ export function generateBoard(
|
|
|
460
541
|
path: 'candidates/index.html',
|
|
461
542
|
},
|
|
462
543
|
{ id: 'components', title: 'Components', path: 'components/index.html' },
|
|
544
|
+
{ id: 'references', title: 'References', path: 'references/index.html' },
|
|
463
545
|
]
|
|
464
546
|
|
|
465
547
|
writeTokensPanel(root, outDir)
|
|
466
|
-
writeSurfacesPanel(root, outDir)
|
|
548
|
+
writeSurfacesPanel(root, outDir, isToolkitCheckout)
|
|
467
549
|
writeWireframesPanel(root, outDir)
|
|
468
550
|
writeCandidatesPanel(root, outDir)
|
|
469
|
-
writeComponentsPanel(root, outDir)
|
|
551
|
+
writeComponentsPanel(root, outDir, isToolkitCheckout)
|
|
552
|
+
writeReferencesPanel(root, outDir)
|
|
470
553
|
|
|
471
554
|
const indexPath = join(outDir, 'index.html')
|
|
472
555
|
writeFileSync(indexPath, shellHtml(panels))
|
package/src/project-root.ts
CHANGED
|
@@ -28,6 +28,22 @@ const readPackageName = (root: string): string | undefined => {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether `root` is a checkout of this same package, judged by package name
|
|
33
|
+
* rather than by path equality against `PROJECT_ROOT`.
|
|
34
|
+
*
|
|
35
|
+
* A linked worktree tracks the identical `package.json` the main worktree
|
|
36
|
+
* does, so this reads true for either. Comparing `root` against `PROJECT_ROOT`
|
|
37
|
+
* directly reads true only for the one directory this process happened to
|
|
38
|
+
* load its own source from, which is wrong for a caller whose CLI runs out of
|
|
39
|
+
* a linked worktree while `root` correctly points at the main one, or at
|
|
40
|
+
* another worktree of the same repository.
|
|
41
|
+
*/
|
|
42
|
+
export function isOwnCheckout(root: string): boolean {
|
|
43
|
+
const ownName = readPackageName(PROJECT_ROOT)
|
|
44
|
+
return ownName !== undefined && readPackageName(root) === ownName
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
/**
|
|
32
48
|
* Walks upward from `startDir` for the nearest ancestor `package.json`
|
|
33
49
|
* sharing this package's own `name`, and reports its path when that ancestor
|
package/src/surface-root.ts
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Heading } from '@/teach/components/heading'
|
|
2
|
+
import { List } from '@/teach/components/list'
|
|
3
|
+
import { Paragraph } from '@/teach/components/paragraph'
|
|
4
|
+
import { render } from '@/teach/html/jsx-runtime'
|
|
5
|
+
import type { TeachRefused } from '@/teach/workspace'
|
|
6
|
+
|
|
7
|
+
export type LessonBlock =
|
|
8
|
+
| { readonly type: 'heading'; readonly level: 1 | 2; readonly text: string }
|
|
9
|
+
| {
|
|
10
|
+
readonly type: 'paragraph'
|
|
11
|
+
readonly text: string
|
|
12
|
+
readonly lede?: boolean
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
readonly type: 'list'
|
|
16
|
+
readonly items: readonly string[]
|
|
17
|
+
readonly ordered?: boolean
|
|
18
|
+
}
|
|
19
|
+
| { readonly type: 'raw'; readonly html: string }
|
|
20
|
+
|
|
21
|
+
export interface RenderRendered {
|
|
22
|
+
readonly ok: true
|
|
23
|
+
readonly html: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RenderOutcome = RenderRendered | TeachRefused
|
|
27
|
+
|
|
28
|
+
const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
|
|
29
|
+
|
|
30
|
+
function isBlockType(value: unknown): value is LessonBlock['type'] {
|
|
31
|
+
return (BLOCK_TYPES as readonly string[]).includes(value as string)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function badBlock(index: number, message: string): TeachRefused {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: 'bad-input',
|
|
38
|
+
message: `Block ${index}: ${message}`,
|
|
39
|
+
detail: [],
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isString(value: unknown): value is string {
|
|
44
|
+
return typeof value === 'string'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
48
|
+
return Array.isArray(value) && value.every(isString)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderBlock(
|
|
52
|
+
block: unknown,
|
|
53
|
+
index: number,
|
|
54
|
+
): { readonly html: string } | TeachRefused {
|
|
55
|
+
if (typeof block !== 'object' || block === null || !('type' in block)) {
|
|
56
|
+
return badBlock(index, 'not an object carrying a type')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const type = (block as { readonly type: unknown }).type
|
|
60
|
+
|
|
61
|
+
if (!isBlockType(type)) {
|
|
62
|
+
return badBlock(index, `unrecognized type ${JSON.stringify(type)}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields = block as Record<string, unknown>
|
|
66
|
+
|
|
67
|
+
switch (type) {
|
|
68
|
+
case 'heading': {
|
|
69
|
+
if (fields.level !== 1 && fields.level !== 2) {
|
|
70
|
+
return badBlock(index, 'heading needs level 1 or 2')
|
|
71
|
+
}
|
|
72
|
+
if (!isString(fields.text)) {
|
|
73
|
+
return badBlock(index, 'heading needs a string text')
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
html: render(Heading({ level: fields.level, children: fields.text })),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
case 'paragraph': {
|
|
80
|
+
if (!isString(fields.text)) {
|
|
81
|
+
return badBlock(index, 'paragraph needs a string text')
|
|
82
|
+
}
|
|
83
|
+
if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
|
|
84
|
+
return badBlock(index, 'paragraph lede must be a boolean')
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
html: render(Paragraph({ lede: fields.lede, children: fields.text })),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
case 'list': {
|
|
91
|
+
if (!isStringArray(fields.items)) {
|
|
92
|
+
return badBlock(index, 'list needs an items array of strings')
|
|
93
|
+
}
|
|
94
|
+
if (fields.ordered !== undefined && typeof fields.ordered !== 'boolean') {
|
|
95
|
+
return badBlock(index, 'list ordered must be a boolean')
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
html: render(List({ ordered: fields.ordered, items: fields.items })),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case 'raw': {
|
|
102
|
+
if (!isString(fields.html)) {
|
|
103
|
+
return badBlock(index, 'raw needs a string html')
|
|
104
|
+
}
|
|
105
|
+
return { html: fields.html }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
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
|
|
114
|
+
* goes unread, matching how a malformed JSON parse refuses the whole call.
|
|
115
|
+
*/
|
|
116
|
+
export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
|
|
117
|
+
const rendered: string[] = []
|
|
118
|
+
|
|
119
|
+
for (const [index, block] of blocks.entries()) {
|
|
120
|
+
const result = renderBlock(block, index)
|
|
121
|
+
if ('ok' in result) return result
|
|
122
|
+
rendered.push(result.html)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { ok: true, html: rendered.join('\n') }
|
|
126
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Decisions reference
|
|
3
|
+
description: Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decisions reference
|
|
7
|
+
|
|
8
|
+
Applies to `canon/decisions/`. Holds the history a canonical doc used to carry itself: pick rounds, superseded figures, and a decision's rejected alternatives, in a tracked folder nothing loads eagerly. A canonical doc points at a record from its own history section rather than restating it.
|
|
9
|
+
|
|
10
|
+
## Scope
|
|
11
|
+
|
|
12
|
+
Governs `canon/decisions/`: folder layout, the ordinal filename, frontmatter, record sections, and the append-only lifecycle.
|
|
13
|
+
|
|
14
|
+
Does not govern:
|
|
15
|
+
|
|
16
|
+
- Which canonical doc points here and when, and what stays behind in that doc's own body: `architecture.md`, `context.md`, `wireframes.md`, `design.md`, `requirements.md`, once each states its own retirement rule
|
|
17
|
+
- Voice, rhythm, and sentence construction: the `write-human` skill
|
|
18
|
+
- Headings, punctuation, word choice, and file references: `markdown.md`
|
|
19
|
+
|
|
20
|
+
## What a working record looks like
|
|
21
|
+
|
|
22
|
+
A record works when a reader who has never opened the project can follow it from the file alone:
|
|
23
|
+
|
|
24
|
+
- What was decided, stated once, without needing the canonical doc that points here
|
|
25
|
+
- What else was considered, and why each alternative lost
|
|
26
|
+
- Which claim rests on a measurement, and what commit that measurement was read against
|
|
27
|
+
|
|
28
|
+
A record failing these is non-conforming even when it satisfies every shape rule below.
|
|
29
|
+
|
|
30
|
+
## Folder name
|
|
31
|
+
|
|
32
|
+
- `canon/decisions/`, resolved the way every tracked surface is: at `canon/decisions/` in a project that has moved, at `.claude/decisions/` in one that has not.
|
|
33
|
+
- Never add `canon/decisions/index.md` to a `CLAUDE.md` `@` import. A log that loads eagerly rebuilds the bloat it exists to absorb. A canonical doc's own pointer is how a reader reaches a record, one file at a time.
|
|
34
|
+
|
|
35
|
+
## Record filename
|
|
36
|
+
|
|
37
|
+
- Name each record `<nn>-<slug>.md`, a two-digit zero-padded ordinal followed by a kebab-case slug, the same shape a groundwork track's folder takes.
|
|
38
|
+
- The ordinal is the order the record was written in, which is what lets a listing sort by when a decision landed rather than alphabetically by subject.
|
|
39
|
+
- Never renumber an existing record. A later reader cites it by that name, and a record whose number moved is a record a stale citation can no longer find.
|
|
40
|
+
|
|
41
|
+
## Frontmatter
|
|
42
|
+
|
|
43
|
+
- `title` (required): the decision in sentence case
|
|
44
|
+
- `description` (required): one line naming what was decided
|
|
45
|
+
|
|
46
|
+
## Sections
|
|
47
|
+
|
|
48
|
+
Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
|
|
49
|
+
|
|
50
|
+
- `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
|
|
51
|
+
- `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
|
|
52
|
+
- `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
|
|
53
|
+
- `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
|
|
54
|
+
|
|
55
|
+
## Verification anchors
|
|
56
|
+
|
|
57
|
+
A record's reasoning stays correct while the numbers it cites move. The anchor records what a measured claim was read against, so a reader can tell a number that was checked and held from one nobody has looked at since.
|
|
58
|
+
|
|
59
|
+
- Close a `## Measurements` section with a trailing sentence naming the short commit SHA and the ISO date that number was read: `Measured at <short-sha> on <YYYY-MM-DD>.`
|
|
60
|
+
- Anchor on the number alone. A record citing none carries no `## Measurements` section at all.
|
|
61
|
+
- Read an absent section as unchecked rather than as current. A record with no measurements has nothing due a re-read.
|
|
62
|
+
|
|
63
|
+
## Lifecycle
|
|
64
|
+
|
|
65
|
+
- Append-only. A written record is never edited to reflect a later reversal.
|
|
66
|
+
- Write a new record when a later decision supersedes an earlier one, naming the record it supersedes. The old record stays as it was written, since it is history rather than a live statement of the current shape.
|
|
67
|
+
- Never auto-loaded. A canonical doc's own history section links to a record by relative path, and a reader reaches it by following that link, not by the folder loading with the session.
|
|
68
|
+
|
|
69
|
+
## Citation
|
|
70
|
+
|
|
71
|
+
- Never cite `.canon/`. A record restates what it needs, since a clone without the gitignored records folder resolves nothing there.
|
|
72
|
+
- Cite a same-repository pull request or commit the way `publish.md` fixes for any tracked document.
|
|
73
|
+
|
|
74
|
+
## Template
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
---
|
|
78
|
+
title: <Decision, in sentence case>
|
|
79
|
+
description: <one line naming what was decided>
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
# <Decision title>
|
|
83
|
+
|
|
84
|
+
## Context
|
|
85
|
+
|
|
86
|
+
<The problem as it stood, self-contained.>
|
|
87
|
+
|
|
88
|
+
## Decision
|
|
89
|
+
|
|
90
|
+
<What was chosen, and why.>
|
|
91
|
+
|
|
92
|
+
## Alternatives
|
|
93
|
+
|
|
94
|
+
- **<Alternative>.** <Why it lost.>
|
|
95
|
+
- **<Alternative>.** <Why it lost.>
|
|
96
|
+
|
|
97
|
+
## Measurements
|
|
98
|
+
|
|
99
|
+
<The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
|
|
100
|
+
```
|
package/standards/index.md
CHANGED
|
@@ -11,6 +11,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
11
11
|
- [Branch reference](branch.md): Branch naming format and type conventions
|
|
12
12
|
- [Commit reference](commit.md): Commit message format and type conventions
|
|
13
13
|
- [Context entry reference](context.md): Shape and content rules for canon/context/<domain>.md entries
|
|
14
|
+
- [Decisions reference](decisions.md): Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
|
|
14
15
|
- [Design reference](design.md): Shape and content rules for canon/DESIGN.md
|
|
15
16
|
- [Diagram reference](diagrams.md): Shape and content rules for .canon/diagrams/<kind>.md files
|
|
16
17
|
- [Docs reference](docs.md): Reader and jurisdiction, frontmatter, page structure, what a page links out to, the diagram permission, and when a category earns a subfolder
|
|
@@ -14,6 +14,7 @@ canon/
|
|
|
14
14
|
├── ARCHITECTURE.md ← seeded. Technical design decisions and open questions
|
|
15
15
|
├── DESIGN.md ← seeded. Visual intent and the decisions behind it
|
|
16
16
|
├── context/ ← seeded. Per-domain narrative. `index.md` is the discovery anchor.
|
|
17
|
+
├── decisions/ ← seeded. Decision history a canonical doc points at, never loaded eagerly. `<nn>-<slug>.md` records.
|
|
17
18
|
└── wireframes/ ← seeded. Per-surface ASCII layouts. `index.md` is the discovery anchor; `<surface>.md` files hold the sketches and behavior bullets.
|
|
18
19
|
|
|
19
20
|
.claude/
|
|
@@ -22,3 +22,4 @@
|
|
|
22
22
|
- `canon/DESIGN.md`: design tokens and the visual system
|
|
23
23
|
- `canon/context/`: per-domain narrative (how a domain is structured, decisions, gotchas), indexed via `canon/context/index.md`
|
|
24
24
|
- `canon/wireframes/`: per-surface ASCII layouts loaded on demand, indexed via `canon/wireframes/index.md`
|
|
25
|
+
- `canon/decisions/`: decision history a project doc points at, never loaded eagerly
|