@erclx/canon 4.79.0 → 4.81.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/canon-rollout/SKILL.md +1 -1
- package/claude/skills/design-extract/SKILL.md +5 -1
- package/claude/skills/git-pr/SKILL.md +3 -3
- package/claude/skills/git-pr/references/labels.md +1 -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/audits.md +2 -2
- package/docs/agents/commands.md +6 -4
- package/docs/agents/design-board.md +13 -11
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/label-coverage.md +1 -1
- package/docs/agents/teach.md +16 -0
- package/docs/target-projects.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -0
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/package.json +1 -1
- package/src/audits/baseline.ts +17 -5
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/design.ts +24 -8
- package/src/commands/sync.ts +1 -1
- package/src/commands/teach.ts +118 -0
- package/src/design/board.ts +130 -47
- package/src/gate/measures.ts +4 -4
- package/src/labels/map.ts +19 -5
- package/src/legacy-path.ts +15 -0
- package/src/migrate/plan.ts +1 -0
- package/src/migrate/surface-roots.ts +5 -6
- package/src/project-root.ts +16 -0
- package/src/surface-root.ts +7 -14
- package/src/sync/stamp.ts +22 -17
- package/src/targets/sweep.ts +6 -2
- package/src/teach/render.ts +126 -0
package/src/commands/design.ts
CHANGED
|
@@ -11,12 +11,17 @@ import { buildDesignCss } from '@/design/css'
|
|
|
11
11
|
import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
|
|
12
12
|
import { renderDesignDoc } from '@/design/render'
|
|
13
13
|
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
checkoutMismatchWarning,
|
|
16
|
+
isOwnCheckout,
|
|
17
|
+
PROJECT_ROOT,
|
|
18
|
+
} from '@/project-root'
|
|
15
19
|
import { creationRel } from '@/record-root'
|
|
16
20
|
import { surfaceDir } from '@/surface-root'
|
|
17
21
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
18
22
|
import { resolveTarget } from '@/target'
|
|
19
23
|
import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
|
|
24
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
20
25
|
|
|
21
26
|
export function register(program: Command): void {
|
|
22
27
|
const design = program
|
|
@@ -133,33 +138,44 @@ export function register(program: Command): void {
|
|
|
133
138
|
design
|
|
134
139
|
.command('board')
|
|
135
140
|
.description(
|
|
136
|
-
'Generate the design board, an index over
|
|
141
|
+
'Generate the design board, an index over a project’s design surfaces',
|
|
137
142
|
)
|
|
138
143
|
.option(
|
|
139
144
|
'-o, --out <path>',
|
|
140
145
|
'Output directory',
|
|
141
146
|
creationRel(process.cwd(), 'review', 'board'),
|
|
142
147
|
)
|
|
148
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
143
149
|
.addHelpText(
|
|
144
150
|
'after',
|
|
145
151
|
[
|
|
146
152
|
'',
|
|
147
|
-
'
|
|
148
|
-
'
|
|
149
|
-
'
|
|
150
|
-
'
|
|
153
|
+
'Reads its sources from --root, defaulting to the main worktree of',
|
|
154
|
+
'whatever project the caller stands in, and writes a static page',
|
|
155
|
+
'set, never installed or synced. The surfaces panel’s landing-page',
|
|
156
|
+
'half and the whole components panel render only when that root is',
|
|
157
|
+
'this toolkit’s own checkout. Open the result with canon serve <out>.',
|
|
151
158
|
'',
|
|
152
159
|
].join('\n'),
|
|
153
160
|
)
|
|
154
|
-
.action((opts: { out: string }) => {
|
|
161
|
+
.action(async (opts: { out: string; root?: string }) => {
|
|
155
162
|
const outDir = resolve(process.cwd(), opts.out)
|
|
163
|
+
const root = opts.root
|
|
164
|
+
? resolve(process.cwd(), opts.root)
|
|
165
|
+
: await mainWorktreeRoot()
|
|
166
|
+
const isToolkitCheckout = isOwnCheckout(root)
|
|
156
167
|
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
157
168
|
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
158
169
|
process.stderr.write(
|
|
159
170
|
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Generate design board${NC}\n`,
|
|
160
171
|
)
|
|
161
172
|
if (mismatch !== undefined) logWarn(mismatch)
|
|
162
|
-
const result = generateBoard(
|
|
173
|
+
const result = generateBoard(
|
|
174
|
+
root,
|
|
175
|
+
outDir,
|
|
176
|
+
process.cwd(),
|
|
177
|
+
isToolkitCheckout,
|
|
178
|
+
)
|
|
163
179
|
if (!result.ok) {
|
|
164
180
|
process.stderr.write(
|
|
165
181
|
`${GREY}│${NC} ${RED}✗${NC} ${result.detail}\n${GREY}└${NC}\n`,
|
package/src/commands/sync.ts
CHANGED
|
@@ -101,7 +101,7 @@ function renderCheck(report: CheckReport): void {
|
|
|
101
101
|
|
|
102
102
|
if (report.stampAtLegacyPath) {
|
|
103
103
|
logWarn(
|
|
104
|
-
'Stamp found at a retired path. Move it to
|
|
104
|
+
'Stamp found at a retired path. Move it to canon/config/config.json.',
|
|
105
105
|
)
|
|
106
106
|
}
|
|
107
107
|
|
package/src/commands/teach.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { relative } from 'node:path'
|
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
4
|
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
5
|
+
import { type RenderOutcome, renderLessonBody } from '@/teach/render'
|
|
5
6
|
import {
|
|
6
7
|
defineTerms,
|
|
7
8
|
type ListOutcome,
|
|
@@ -81,6 +82,10 @@ interface NavCommandOptions {
|
|
|
81
82
|
readonly root?: string
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
interface RenderCommandOptions {
|
|
86
|
+
readonly json?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
export function register(program: Command): void {
|
|
85
90
|
const teach = program
|
|
86
91
|
.command('teach')
|
|
@@ -357,6 +362,36 @@ export function register(program: Command): void {
|
|
|
357
362
|
.action(async (topic: string | undefined, opts: NavCommandOptions) => {
|
|
358
363
|
process.exitCode = await runNav(topic, opts)
|
|
359
364
|
})
|
|
365
|
+
|
|
366
|
+
teach
|
|
367
|
+
.command('render')
|
|
368
|
+
.description('Render a lesson body block list to HTML')
|
|
369
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
370
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
371
|
+
.addHelpText(
|
|
372
|
+
'after',
|
|
373
|
+
[
|
|
374
|
+
'',
|
|
375
|
+
'Exit codes:',
|
|
376
|
+
' 0 the block list rendered',
|
|
377
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
378
|
+
'',
|
|
379
|
+
'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
|
|
380
|
+
'list, or raw block, and renders it through the same components the',
|
|
381
|
+
'fixture lesson is generated from. Content the three cannot express',
|
|
382
|
+
'takes type raw, carrying its own html verbatim, unescaped.',
|
|
383
|
+
'',
|
|
384
|
+
'Takes no topic and no --root: the verb is a stateless transform,',
|
|
385
|
+
'reading nothing off a workspace on disk.',
|
|
386
|
+
'',
|
|
387
|
+
'Examples:',
|
|
388
|
+
' echo \'[{"type":"heading","level":1,"text":"Compass bearings"}]\' | canon teach render --json',
|
|
389
|
+
'',
|
|
390
|
+
].join('\n'),
|
|
391
|
+
)
|
|
392
|
+
.action(async (opts: RenderCommandOptions) => {
|
|
393
|
+
process.exitCode = await runRender(opts)
|
|
394
|
+
})
|
|
360
395
|
}
|
|
361
396
|
|
|
362
397
|
interface StylesheetCommandOptions {
|
|
@@ -403,6 +438,17 @@ function collect(value: string, previous: string[]): string[] {
|
|
|
403
438
|
return [...previous, value]
|
|
404
439
|
}
|
|
405
440
|
|
|
441
|
+
function readStdin(): Promise<string> {
|
|
442
|
+
return new Promise((resolveStream, rejectStream) => {
|
|
443
|
+
const chunks: Buffer[] = []
|
|
444
|
+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
445
|
+
process.stdin.on('end', () =>
|
|
446
|
+
resolveStream(Buffer.concat(chunks).toString('utf8')),
|
|
447
|
+
)
|
|
448
|
+
process.stdin.on('error', rejectStream)
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
406
452
|
/**
|
|
407
453
|
* Splits every pair or reports the ones that carry no separator. Both halves
|
|
408
454
|
* are reported together, so a caller passing four pairs learns about all the
|
|
@@ -648,6 +694,78 @@ async function runNav(
|
|
|
648
694
|
return reportNav(await generateNav(root, topic), emitJson, root)
|
|
649
695
|
}
|
|
650
696
|
|
|
697
|
+
async function runRender(opts: RenderCommandOptions): Promise<number> {
|
|
698
|
+
const emitJson = opts.json ?? false
|
|
699
|
+
|
|
700
|
+
if (process.stdin.isTTY) {
|
|
701
|
+
return reportRenderRefusal(
|
|
702
|
+
badInput(
|
|
703
|
+
"No blocks on stdin. Pipe a JSON array: echo '[...]' | canon teach render",
|
|
704
|
+
),
|
|
705
|
+
emitJson,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const body = (await readStdin()).trim()
|
|
710
|
+
|
|
711
|
+
if (!body) {
|
|
712
|
+
return reportRenderRefusal(
|
|
713
|
+
badInput('Empty stdin. Pipe a JSON array of blocks.'),
|
|
714
|
+
emitJson,
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let parsed: unknown
|
|
719
|
+
try {
|
|
720
|
+
parsed = JSON.parse(body)
|
|
721
|
+
} catch {
|
|
722
|
+
return reportRenderRefusal(badInput('Malformed JSON on stdin.'), emitJson)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (!Array.isArray(parsed)) {
|
|
726
|
+
return reportRenderRefusal(
|
|
727
|
+
badInput('Stdin must be a JSON array of blocks.'),
|
|
728
|
+
emitJson,
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return reportRender(renderLessonBody(parsed), emitJson)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function reportRenderRefusal(refused: TeachRefused, emitJson: boolean): number {
|
|
736
|
+
if (emitJson) {
|
|
737
|
+
process.stderr.write(`${refused.message}\n`)
|
|
738
|
+
process.stdout.write(
|
|
739
|
+
`${JSON.stringify({
|
|
740
|
+
ok: false,
|
|
741
|
+
reason: refused.reason,
|
|
742
|
+
message: refused.message,
|
|
743
|
+
})}\n`,
|
|
744
|
+
)
|
|
745
|
+
return 1
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
intro('canon teach render')
|
|
749
|
+
logStep('Refused')
|
|
750
|
+
logError(refused.message)
|
|
751
|
+
outro()
|
|
752
|
+
return 1
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function reportRender(outcome: RenderOutcome, emitJson: boolean): number {
|
|
756
|
+
if (!outcome.ok) return reportRenderRefusal(outcome, emitJson)
|
|
757
|
+
|
|
758
|
+
if (emitJson) {
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
`${JSON.stringify({ ok: true, html: outcome.html })}\n`,
|
|
761
|
+
)
|
|
762
|
+
return 0
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
process.stdout.write(`${outcome.html}\n`)
|
|
766
|
+
return 0
|
|
767
|
+
}
|
|
768
|
+
|
|
651
769
|
function reportNav(
|
|
652
770
|
outcome: NavOutcome,
|
|
653
771
|
emitJson: boolean,
|
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/gate/measures.ts
CHANGED
|
@@ -126,11 +126,11 @@ export const SANDBOX_ASSERTED_FLOOR = 26
|
|
|
126
126
|
* Where the retained counts the audit stage compares each run against are
|
|
127
127
|
* read, relative to `root`.
|
|
128
128
|
*
|
|
129
|
-
* A function rather than a spelled constant, since the read
|
|
129
|
+
* A function rather than a spelled constant, since the read resolves at
|
|
130
130
|
* either surface root and a constant naming one of them would be believed of
|
|
131
|
-
* a project that has moved. `canon audits run` owns writing it,
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* a project that has moved. `canon audits run` owns writing it, at
|
|
132
|
+
* `canon/config/baseline.json` now that the write has flipped, falling back to
|
|
133
|
+
* `.claude/canon/baseline.json` for a target that has not moved.
|
|
134
134
|
*/
|
|
135
135
|
export function auditsBaselineRel(root: string): string {
|
|
136
136
|
return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
|
package/src/labels/map.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { resolveExisting } from '@/legacy-path'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Where a project declares its pull request label map
|
|
6
|
+
* Where a project declares its pull request label map.
|
|
6
7
|
*
|
|
7
8
|
* The file has already moved once, and that relocation rewrote every mention
|
|
8
|
-
* across four surfaces.
|
|
9
|
-
*
|
|
9
|
+
* across four surfaces. `LEGACY_MAP_REL` below is the one other place code
|
|
10
|
+
* spells it, kept only as a read fallback, so the next move is still one edit
|
|
11
|
+
* to the write path rather than a sweep.
|
|
10
12
|
*/
|
|
11
|
-
export const MAP_REL = join('
|
|
13
|
+
export const MAP_REL = join('canon', 'config', 'pr-labels.toml')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where the map lived before this move, `.claude/canon/pr-labels.toml`.
|
|
17
|
+
* `readLabelMap` falls back to it so a project that has not moved still reads
|
|
18
|
+
* its declared map.
|
|
19
|
+
*/
|
|
20
|
+
const LEGACY_MAP_REL = join('.claude', 'canon', 'pr-labels.toml')
|
|
12
21
|
|
|
13
22
|
/** A label name and the path prefixes that earn it, in the map's own order. */
|
|
14
23
|
export interface DomainRow {
|
|
@@ -90,9 +99,14 @@ export function parseLabelMap(source: string): LabelMap {
|
|
|
90
99
|
|
|
91
100
|
/** Reads the map a project declares at `root`, or says why it could not. */
|
|
92
101
|
export function readLabelMap(root: string): LabelMap {
|
|
102
|
+
const path = resolveExisting([
|
|
103
|
+
join(root, MAP_REL),
|
|
104
|
+
join(root, LEGACY_MAP_REL),
|
|
105
|
+
])
|
|
106
|
+
|
|
93
107
|
let source: string
|
|
94
108
|
try {
|
|
95
|
-
source = readFileSync(
|
|
109
|
+
source = readFileSync(path, 'utf8')
|
|
96
110
|
} catch {
|
|
97
111
|
return { kind: 'refused', reason: 'no-map' }
|
|
98
112
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a fallback chain resolves for reading: the first path that exists,
|
|
5
|
+
* current first, or the first candidate when none exists yet, which is
|
|
6
|
+
* always the write target.
|
|
7
|
+
*
|
|
8
|
+
* `src/audits/baseline.ts`, `src/labels/map.ts`, and `src/sync/stamp.ts` each
|
|
9
|
+
* trail a file that moved, and each used to reason about its own chain
|
|
10
|
+
* separately. The tuple type keeps a caller from passing an empty list, since
|
|
11
|
+
* every chain here has a write target to fall back to.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveExisting(paths: readonly [string, ...string[]]): string {
|
|
14
|
+
return paths.find((path) => existsSync(path)) ?? paths[0]
|
|
15
|
+
}
|
package/src/migrate/plan.ts
CHANGED
|
@@ -20,13 +20,12 @@ const TO_ROOT: SurfaceRoot = 'canon'
|
|
|
20
20
|
/**
|
|
21
21
|
* Every entry this move relocates, at the name `.claude/` gives it.
|
|
22
22
|
*
|
|
23
|
-
* Read from `SURFACE_ENTRIES` rather than restated
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Read from `SURFACE_ENTRIES` rather than restated. `canon` names the install
|
|
24
|
+
* stamp folder and is the one entry whose name differs by root, which
|
|
25
|
+
* `sourcePrefix` and `destinationPrefix` already resolve through `spell`
|
|
26
|
+
* rather than restating the variant here.
|
|
26
27
|
*/
|
|
27
|
-
export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
|
|
28
|
-
(entry) => entry !== 'canon',
|
|
29
|
-
)
|
|
28
|
+
export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
|
|
30
29
|
|
|
31
30
|
function sourcePrefix(entry: string): string {
|
|
32
31
|
return join(FROM_ROOT, spell(FROM_ROOT, entry))
|
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
|