@erclx/canon 4.75.1 → 4.77.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/draft-and-pick/SKILL.md +3 -3
- package/claude/skills/draft-figure/REQUIREMENT.md +42 -0
- package/claude/skills/draft-figure/SKILL.md +93 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/design-board.md +31 -0
- package/docs/agents/index.md +1 -0
- package/docs/workflow/ai-workflow.md +18 -17
- package/package.json +2 -1
- package/scripts/core/check-gallery-exclusion.sh +50 -0
- package/src/claude/cases/workflow.ts +5 -0
- package/src/cli.ts +2 -1
- package/src/commands/design.ts +46 -1
- package/src/design/board.ts +475 -0
- package/src/gate/measures.ts +4 -2
- package/src/shipped/references.ts +41 -7
- package/src/teach/components/heading.tsx +11 -0
- package/src/teach/components/list.tsx +17 -0
- package/src/teach/components/paragraph.tsx +12 -0
- package/src/teach/html/jsx-dev-runtime.ts +13 -0
- package/src/teach/html/jsx-runtime.ts +113 -0
- package/src/teach/html/raw.ts +28 -0
- package/src/teach/render-fixture.tsx +95 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join, relative, sep } from 'node:path'
|
|
11
|
+
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
12
|
+
import { renderDesignDoc } from '@/design/render'
|
|
13
|
+
import { colorValue } from '@/design/tokens'
|
|
14
|
+
import { recordDir } from '@/record-root'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one named site for this repository's wireframe corpus. `standards/`
|
|
18
|
+
* moves it to `canon/wireframes/` under the answered
|
|
19
|
+
* `.canon/intake/88-surface-roots-and-corpus-debt/` item 1, and that move
|
|
20
|
+
* retargets this constant alone rather than a literal repeated per panel.
|
|
21
|
+
*/
|
|
22
|
+
export const WIREFRAME_DIR = join('.claude', 'wireframes')
|
|
23
|
+
|
|
24
|
+
/** Landing page for the built site the surfaces panel iframes when present. */
|
|
25
|
+
const WEB_DIST = join('web', 'dist')
|
|
26
|
+
const WEB_DIST_ENTRY = 'index.html'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The gallery build, generated by `bun run web:gallery` through
|
|
30
|
+
* `web/gallery.config.mjs`. That config's own `srcDir` is `web/gallery-src`,
|
|
31
|
+
* which `web:build`'s config never reads, so this output never reaches
|
|
32
|
+
* `web/dist`.
|
|
33
|
+
*/
|
|
34
|
+
const WEB_GALLERY_DIST = join('web', 'gallery-dist')
|
|
35
|
+
const WEB_GALLERY_DIST_ENTRY = 'index.html'
|
|
36
|
+
|
|
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
|
+
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
|
|
58
|
+
|
|
59
|
+
export interface BoardPanel {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly title: string
|
|
62
|
+
/** Relative to the board's own output directory. */
|
|
63
|
+
readonly path: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BoardResult {
|
|
67
|
+
readonly ok: true
|
|
68
|
+
readonly outDir: string
|
|
69
|
+
readonly indexPath: string
|
|
70
|
+
readonly panels: readonly BoardPanel[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BoardRefused {
|
|
74
|
+
readonly ok: false
|
|
75
|
+
readonly reason: 'unsafe-out'
|
|
76
|
+
readonly detail: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type BoardOutcome = BoardResult | BoardRefused
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether clearing `outDir` would take a protected directory down with it:
|
|
83
|
+
* the directory equals one of them, or contains one.
|
|
84
|
+
*
|
|
85
|
+
* `generateBoard` clears its output directory on every run, and `--out` is
|
|
86
|
+
* resolved against the caller's cwd while the panels read from `PROJECT_ROOT`.
|
|
87
|
+
* Those agree in the ordinary case and diverge in exactly one: a second
|
|
88
|
+
* checkout, where a global `canon` resolves `PROJECT_ROOT` to a different
|
|
89
|
+
* tree than the one the caller stands in. Guarding `PROJECT_ROOT` alone misses
|
|
90
|
+
* that case, since `--out .` then resolves under the caller's own cwd, which
|
|
91
|
+
* shares no containment with the unrelated root the guard compared it to.
|
|
92
|
+
*/
|
|
93
|
+
function wouldDeleteRoot(protect: readonly string[], outDir: string): boolean {
|
|
94
|
+
return protect.some((dir) => dir === outDir || dir.startsWith(outDir + sep))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function escapeHtml(value: string): string {
|
|
98
|
+
return value
|
|
99
|
+
.replace(/&/g, '&')
|
|
100
|
+
.replace(/</g, '<')
|
|
101
|
+
.replace(/>/g, '>')
|
|
102
|
+
.replace(/"/g, '"')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const THEME_TOGGLE_BUTTON = `<button class="theme-toggle" type="button" aria-label="Switch between light and dark"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>`
|
|
106
|
+
|
|
107
|
+
const THEME_TOGGLE_SCRIPT = [
|
|
108
|
+
'<script>(function(){',
|
|
109
|
+
'var r=document.documentElement;',
|
|
110
|
+
'try{var s=localStorage.getItem("board-theme");if(s)r.dataset.theme=s;}catch(e){}',
|
|
111
|
+
'document.addEventListener("click",function(e){',
|
|
112
|
+
'var b=e.target.closest(".theme-toggle");if(!b)return;',
|
|
113
|
+
'var dark=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);',
|
|
114
|
+
'r.dataset.theme=dark?"light":"dark";',
|
|
115
|
+
'try{localStorage.setItem("board-theme",r.dataset.theme);}catch(e){}',
|
|
116
|
+
'});',
|
|
117
|
+
'})();</script>',
|
|
118
|
+
].join('')
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Highlights the nav link for whichever section fills the most of the
|
|
122
|
+
* viewport, following the same largest-intersection-ratio rule the landing
|
|
123
|
+
* page's own floating pill uses, so a reader scrolling through the four
|
|
124
|
+
* panels sees the pill track their position rather than sitting static.
|
|
125
|
+
*/
|
|
126
|
+
const SCROLLSPY_SCRIPT = [
|
|
127
|
+
'<script>(function(){',
|
|
128
|
+
'var links={};',
|
|
129
|
+
'document.querySelectorAll("nav a[href^=\\"#\\"]").forEach(function(a){links[a.getAttribute("href").slice(1)]=a;});',
|
|
130
|
+
'var ratios={};',
|
|
131
|
+
'var current="";',
|
|
132
|
+
'var observer=new IntersectionObserver(function(entries){',
|
|
133
|
+
'entries.forEach(function(entry){ratios[entry.target.id]=entry.isIntersecting?entry.intersectionRatio:0;});',
|
|
134
|
+
'var bestId="";',
|
|
135
|
+
'var bestRatio=0;',
|
|
136
|
+
'Object.keys(ratios).forEach(function(id){if(ratios[id]>bestRatio){bestRatio=ratios[id];bestId=id;}});',
|
|
137
|
+
'if(bestId&&bestId!==current){',
|
|
138
|
+
'if(links[current])links[current].classList.remove("active");',
|
|
139
|
+
'current=bestId;',
|
|
140
|
+
'if(links[current])links[current].classList.add("active");',
|
|
141
|
+
'}',
|
|
142
|
+
'},{threshold:[0,0.25,0.5,0.75,1]});',
|
|
143
|
+
'Object.keys(links).forEach(function(id){',
|
|
144
|
+
'var section=document.getElementById(id);',
|
|
145
|
+
'if(section)observer.observe(section);',
|
|
146
|
+
'});',
|
|
147
|
+
'})();</script>',
|
|
148
|
+
].join('')
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The same brand mark `src/design/render.ts` embeds in its own preview,
|
|
152
|
+
* colored with the dark accent since the board's default chrome is dark
|
|
153
|
+
* where that preview's is light. Duplicated here rather than imported,
|
|
154
|
+
* following that module's own precedent of three independently-colored
|
|
155
|
+
* copies rather than a shared icon helper for the first second caller.
|
|
156
|
+
*/
|
|
157
|
+
function faviconLink(): string {
|
|
158
|
+
const color = colorValue('accent') ?? '#e0724b'
|
|
159
|
+
const href = `data:image/svg+xml,${encodeURIComponent(
|
|
160
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${color}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${color}" /></svg>`,
|
|
161
|
+
)}`
|
|
162
|
+
return `<link rel="icon" href="${href}">`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The board shell's own chrome, read off the toolkit's design source. */
|
|
166
|
+
function shellChrome(): string {
|
|
167
|
+
const roles: ReadonlyArray<readonly [string, string]> = [
|
|
168
|
+
['background', 'background'],
|
|
169
|
+
['surface', 'surface'],
|
|
170
|
+
['text', 'text'],
|
|
171
|
+
['muted', 'muted'],
|
|
172
|
+
['border', 'border'],
|
|
173
|
+
['accent', 'accent'],
|
|
174
|
+
]
|
|
175
|
+
const dark = roles
|
|
176
|
+
.map(([name, role]) => {
|
|
177
|
+
const value = colorValue(role)
|
|
178
|
+
return value === undefined ? '' : ` --board-${name}: ${value};`
|
|
179
|
+
})
|
|
180
|
+
.filter((line) => line !== '')
|
|
181
|
+
const light = roles
|
|
182
|
+
.map(([name, role]) => {
|
|
183
|
+
const value = colorValue(`light-${role}`)
|
|
184
|
+
return value === undefined ? '' : ` --board-${name}: ${value};`
|
|
185
|
+
})
|
|
186
|
+
.filter((line) => line !== '')
|
|
187
|
+
|
|
188
|
+
return `:root {\n${dark.join('\n')}\n}\n[data-theme='light'] {\n${light.join('\n')}\n}`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function shellHtml(panels: readonly BoardPanel[]): string {
|
|
192
|
+
const navLinks = panels
|
|
193
|
+
.map((panel) => `<a href="#${panel.id}">${escapeHtml(panel.title)}</a>`)
|
|
194
|
+
.join('')
|
|
195
|
+
|
|
196
|
+
const sections = panels
|
|
197
|
+
.map(
|
|
198
|
+
(panel, index) =>
|
|
199
|
+
`<section id="${panel.id}">\n<div class="eyebrow">Panel 0${index + 1}</div>\n<h2>${escapeHtml(panel.title)}</h2>\n<iframe src="${panel.path}" loading="lazy"></iframe>\n</section>`,
|
|
200
|
+
)
|
|
201
|
+
.join('\n')
|
|
202
|
+
|
|
203
|
+
return `<!doctype html>
|
|
204
|
+
<html lang="en">
|
|
205
|
+
<head>
|
|
206
|
+
<meta charset="utf-8">
|
|
207
|
+
${faviconLink()}
|
|
208
|
+
<title>Design board</title>
|
|
209
|
+
<style>
|
|
210
|
+
${shellChrome()}
|
|
211
|
+
* { box-sizing: border-box; }
|
|
212
|
+
body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; color: var(--board-text); background: var(--board-background); }
|
|
213
|
+
header { padding: 2.5rem 3rem 1.5rem; }
|
|
214
|
+
h1 { margin: 0 0 0.4rem; font-size: 1.6rem; font-weight: 600; letter-spacing: -0.01em; }
|
|
215
|
+
.meta { color: var(--board-muted); font-size: 0.85rem; margin: 0; }
|
|
216
|
+
nav { position: fixed; top: 1.25rem; left: 50%; transform: translateX(-50%); z-index: 1; display: flex; align-items: center; gap: 1.25rem; padding: 0.5rem 0.6rem 0.5rem 1.1rem; background: var(--board-surface); border: 1px solid var(--board-border); border-radius: 999px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); white-space: nowrap; }
|
|
217
|
+
nav a { color: var(--board-muted); text-decoration: none; font-size: 0.8rem; }
|
|
218
|
+
nav a:hover { color: var(--board-accent); }
|
|
219
|
+
nav a.active { color: var(--board-accent); }
|
|
220
|
+
.theme-toggle { display: flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; background: var(--board-background); color: var(--board-text); border: 1px solid var(--board-border); border-radius: 50%; padding: 0; cursor: pointer; }
|
|
221
|
+
.theme-toggle .sun { display: none; }
|
|
222
|
+
.theme-toggle .moon { display: block; }
|
|
223
|
+
[data-theme='light'] .theme-toggle .sun { display: block; }
|
|
224
|
+
[data-theme='light'] .theme-toggle .moon { display: none; }
|
|
225
|
+
.theme-toggle svg { width: 0.95rem; height: 0.95rem; }
|
|
226
|
+
main { padding: 0 3rem 4.5rem; }
|
|
227
|
+
section { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--board-border); scroll-margin-top: 4.5rem; }
|
|
228
|
+
.eyebrow { color: var(--board-accent); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.35rem; }
|
|
229
|
+
section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; font-weight: 600; }
|
|
230
|
+
iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); border-radius: 4px; background: var(--board-surface); }
|
|
231
|
+
</style>
|
|
232
|
+
</head>
|
|
233
|
+
<body>
|
|
234
|
+
<header>
|
|
235
|
+
<h1>Design board</h1>
|
|
236
|
+
<p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
|
|
237
|
+
</header>
|
|
238
|
+
<nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
|
|
239
|
+
<main>
|
|
240
|
+
${sections}
|
|
241
|
+
</main>
|
|
242
|
+
${THEME_TOGGLE_SCRIPT}
|
|
243
|
+
${SCROLLSPY_SCRIPT}
|
|
244
|
+
</body>
|
|
245
|
+
</html>
|
|
246
|
+
`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function panelPage(title: string, body: string): string {
|
|
250
|
+
const accent = colorValue('light-accent') ?? '#a4471c'
|
|
251
|
+
const surface = colorValue('light-surface') ?? '#f4efe6'
|
|
252
|
+
const border = colorValue('light-border') ?? '#e4dcd0'
|
|
253
|
+
const muted = colorValue('light-muted') ?? '#726b62'
|
|
254
|
+
const text = colorValue('light-text') ?? '#1a1815'
|
|
255
|
+
const background = colorValue('light-background') ?? '#faf7f2'
|
|
256
|
+
|
|
257
|
+
return `<!doctype html>
|
|
258
|
+
<html lang="en">
|
|
259
|
+
<head>
|
|
260
|
+
<meta charset="utf-8">
|
|
261
|
+
${faviconLink()}
|
|
262
|
+
<title>${escapeHtml(title)}</title>
|
|
263
|
+
<style>
|
|
264
|
+
* { box-sizing: border-box; }
|
|
265
|
+
body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; padding: 1.75rem 2.25rem; color: ${text}; background: ${background}; }
|
|
266
|
+
h2 { margin: 2rem 0 0.6rem; padding-top: 1.1rem; border-top: 1px solid ${border}; font-size: 1rem; font-weight: 600; }
|
|
267
|
+
h2:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
|
|
268
|
+
p { color: ${muted}; font-size: 0.85rem; }
|
|
269
|
+
table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
|
|
270
|
+
td, th { padding: 0.4rem 0.6rem; border-bottom: 1px solid ${border}; text-align: left; }
|
|
271
|
+
pre { white-space: pre-wrap; background: ${surface}; border-radius: 4px; padding: 1rem; font-size: 0.8rem; }
|
|
272
|
+
img { border-radius: 4px; border: 1px solid ${border}; margin-top: 0.5rem; }
|
|
273
|
+
a { color: ${accent}; }
|
|
274
|
+
.empty { color: ${muted}; font-style: italic; background: ${surface}; border-radius: 4px; padding: 0.9rem 1rem; }
|
|
275
|
+
</style>
|
|
276
|
+
</head>
|
|
277
|
+
<body>
|
|
278
|
+
${body}
|
|
279
|
+
</body>
|
|
280
|
+
</html>
|
|
281
|
+
`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeTokensPanel(root: string, outDir: string): void {
|
|
285
|
+
const sourcePath = join(root, DESIGN_DOCUMENT)
|
|
286
|
+
const dir = join(outDir, 'tokens')
|
|
287
|
+
|
|
288
|
+
if (!existsSync(sourcePath)) {
|
|
289
|
+
mkdirSync(dir, { recursive: true })
|
|
290
|
+
writeFileSync(
|
|
291
|
+
join(dir, 'index.html'),
|
|
292
|
+
panelPage(
|
|
293
|
+
'Tokens',
|
|
294
|
+
`<p class="empty">No ${DESIGN_DOCUMENT} at the repository root.</p>`,
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
renderDesignDoc(sourcePath, dir)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeWireframesPanel(root: string, outDir: string): void {
|
|
304
|
+
const dir = join(outDir, 'wireframes')
|
|
305
|
+
mkdirSync(dir, { recursive: true })
|
|
306
|
+
|
|
307
|
+
const sections = WIREFRAMES.map((entry) => {
|
|
308
|
+
const sourcePath = join(root, WIREFRAME_DIR, entry.path)
|
|
309
|
+
if (!existsSync(sourcePath)) {
|
|
310
|
+
return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
|
|
311
|
+
}
|
|
312
|
+
const text = readFileSync(sourcePath, 'utf8')
|
|
313
|
+
return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
|
|
314
|
+
}).join('\n')
|
|
315
|
+
|
|
316
|
+
writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Copies a built directory whole, filtering nothing, into the board's own tree. */
|
|
320
|
+
function copyBuilt(source: string, dest: string): void {
|
|
321
|
+
rmSync(dest, { recursive: true, force: true })
|
|
322
|
+
cpSync(source, dest, { recursive: true })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function writeSurfacesPanel(root: string, outDir: string): void {
|
|
326
|
+
const dir = join(outDir, 'surfaces')
|
|
327
|
+
mkdirSync(dir, { recursive: true })
|
|
328
|
+
|
|
329
|
+
const distSource = join(root, WEB_DIST)
|
|
330
|
+
const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
|
|
331
|
+
? (copyBuilt(distSource, join(dir, 'landing')),
|
|
332
|
+
'<iframe src="landing/index.html" loading="lazy"></iframe>')
|
|
333
|
+
: `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
|
|
334
|
+
|
|
335
|
+
const teachSource = recordDir(root, 'teach')
|
|
336
|
+
const teachBody = existsSync(join(teachSource, 'index.html'))
|
|
337
|
+
? (copyBuilt(teachSource, join(dir, 'teach')),
|
|
338
|
+
'<iframe src="teach/index.html" loading="lazy"></iframe>')
|
|
339
|
+
: `<p class="empty">${relative(root, teachSource)} is gitignored and machine-local, so this panel renders empty in a fresh clone and on CI.</p>`
|
|
340
|
+
|
|
341
|
+
writeFileSync(
|
|
342
|
+
join(dir, 'index.html'),
|
|
343
|
+
panelPage(
|
|
344
|
+
'Surfaces',
|
|
345
|
+
`<h2>Landing page</h2>\n${landingBody}\n<h2>Teach workspaces</h2>\n${teachBody}`,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function writeComponentsPanel(root: string, outDir: string): void {
|
|
351
|
+
const dir = join(outDir, 'components')
|
|
352
|
+
mkdirSync(dir, { recursive: true })
|
|
353
|
+
|
|
354
|
+
const gallerySource = join(root, WEB_GALLERY_DIST)
|
|
355
|
+
const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
|
|
356
|
+
? (copyBuilt(gallerySource, join(dir, 'gallery')),
|
|
357
|
+
'<iframe src="gallery/index.html" loading="lazy"></iframe>')
|
|
358
|
+
: `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
|
|
359
|
+
|
|
360
|
+
writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isImage(name: string): boolean {
|
|
364
|
+
return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Every image file directly inside an evidence arm folder, one level deep. */
|
|
368
|
+
function imagesIn(dir: string): string[] {
|
|
369
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
370
|
+
.filter((entry) => entry.isFile() && isImage(entry.name))
|
|
371
|
+
.map((entry) => entry.name)
|
|
372
|
+
.sort()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function writeCandidatesPanel(root: string, outDir: string): void {
|
|
376
|
+
const dir = join(outDir, 'candidates')
|
|
377
|
+
mkdirSync(dir, { recursive: true })
|
|
378
|
+
|
|
379
|
+
const evidenceDir = recordDir(root, 'review', 'evidence')
|
|
380
|
+
if (!existsSync(evidenceDir)) {
|
|
381
|
+
writeFileSync(
|
|
382
|
+
join(dir, 'index.html'),
|
|
383
|
+
panelPage(
|
|
384
|
+
'Past candidates',
|
|
385
|
+
`<p class="empty">No ${relative(root, evidenceDir)} folder yet.</p>`,
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const folders = readdirSync(evidenceDir, { withFileTypes: true })
|
|
392
|
+
.filter((entry) => entry.isDirectory())
|
|
393
|
+
.map((entry) => entry.name)
|
|
394
|
+
.sort()
|
|
395
|
+
|
|
396
|
+
const found: Array<{ folder: string; images: string[] }> = []
|
|
397
|
+
for (const folder of folders) {
|
|
398
|
+
const images = imagesIn(join(evidenceDir, folder))
|
|
399
|
+
if (images.length > 0) {
|
|
400
|
+
cpSync(join(evidenceDir, folder), join(dir, folder), { recursive: true })
|
|
401
|
+
found.push({ folder, images })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (found.length === 0) {
|
|
406
|
+
writeFileSync(
|
|
407
|
+
join(dir, 'index.html'),
|
|
408
|
+
panelPage(
|
|
409
|
+
'Past candidates',
|
|
410
|
+
`<p class="empty">${folders.length} folders under ${relative(root, evidenceDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const sections = found
|
|
417
|
+
.map(
|
|
418
|
+
({ folder, images }) =>
|
|
419
|
+
`<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
|
|
420
|
+
)
|
|
421
|
+
.join('\n')
|
|
422
|
+
|
|
423
|
+
writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Generates the board's page set into `outDir`, clearing whatever was there.
|
|
428
|
+
*
|
|
429
|
+
* This function is the directory's only writer, per the constraint every
|
|
430
|
+
* caller shares it under: `canon serve` and a future `canon capture` pass
|
|
431
|
+
* both read the result and neither may assume it exists ahead of a run.
|
|
432
|
+
*
|
|
433
|
+
* `cwd` is the caller's own working directory, resolved and passed in
|
|
434
|
+
* explicitly rather than read here, so a test can exercise the checkout-
|
|
435
|
+
* mismatch case without touching the process's real cwd.
|
|
436
|
+
*/
|
|
437
|
+
export function generateBoard(
|
|
438
|
+
root: string,
|
|
439
|
+
outDir: string,
|
|
440
|
+
cwd: string,
|
|
441
|
+
): BoardOutcome {
|
|
442
|
+
if (wouldDeleteRoot([root, cwd], outDir)) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
reason: 'unsafe-out',
|
|
446
|
+
detail: `${outDir} is or contains ${root} or ${cwd}. Refusing to clear it.`,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
451
|
+
mkdirSync(outDir, { recursive: true })
|
|
452
|
+
|
|
453
|
+
const panels: readonly BoardPanel[] = [
|
|
454
|
+
{ id: 'tokens', title: 'Tokens', path: 'tokens/index.html' },
|
|
455
|
+
{ id: 'surfaces', title: 'Surfaces', path: 'surfaces/index.html' },
|
|
456
|
+
{ id: 'wireframes', title: 'Wireframes', path: 'wireframes/index.html' },
|
|
457
|
+
{
|
|
458
|
+
id: 'candidates',
|
|
459
|
+
title: 'Past candidates',
|
|
460
|
+
path: 'candidates/index.html',
|
|
461
|
+
},
|
|
462
|
+
{ id: 'components', title: 'Components', path: 'components/index.html' },
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
writeTokensPanel(root, outDir)
|
|
466
|
+
writeSurfacesPanel(root, outDir)
|
|
467
|
+
writeWireframesPanel(root, outDir)
|
|
468
|
+
writeCandidatesPanel(root, outDir)
|
|
469
|
+
writeComponentsPanel(root, outDir)
|
|
470
|
+
|
|
471
|
+
const indexPath = join(outDir, 'index.html')
|
|
472
|
+
writeFileSync(indexPath, shellHtml(panels))
|
|
473
|
+
|
|
474
|
+
return { ok: true, outDir, indexPath, panels }
|
|
475
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -523,6 +523,8 @@ function describeShippedReference(reference: ShippedReference): string {
|
|
|
523
523
|
return 'a bare standards/ path that has nothing to expand it in an installed plugin cache'
|
|
524
524
|
case 'phase-label':
|
|
525
525
|
return 'a phase label that names a board no target holds'
|
|
526
|
+
case 'rule-path':
|
|
527
|
+
return 'a numbered rule path that reaches a target only through a separate canon gov sync, and only where governance was installed at all'
|
|
526
528
|
}
|
|
527
529
|
}
|
|
528
530
|
|
|
@@ -579,8 +581,8 @@ export const shippedReferences: Measure = async (ctx) => {
|
|
|
579
581
|
),
|
|
580
582
|
failure:
|
|
581
583
|
found.length === 1
|
|
582
|
-
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
583
|
-
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
584
|
+
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
585
|
+
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
@@ -203,6 +203,22 @@ const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
|
|
|
203
203
|
*/
|
|
204
204
|
const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* A `.claude/rules/<segments>/<nnn>-<slug>.md` citation from a body under
|
|
208
|
+
* `claude/skills/`, the installed-path spelling `598-authoring-layout.md`
|
|
209
|
+
* bans a shipped skill body from citing as authority for its own behavior.
|
|
210
|
+
*
|
|
211
|
+
* Anchored on the trailing `\d{3}-[\w-]+\.md` rather than on the bare
|
|
212
|
+
* `.claude/rules/` prefix, which is what keeps a folder mention carrying no
|
|
213
|
+
* number, such as `create-rule`, `memory-review`, and `setup-gov` already
|
|
214
|
+
* write correctly, from matching. The segment group between `rules/` and the
|
|
215
|
+
* numbered file admits both a governance-namespace path
|
|
216
|
+
* (`canon/core/055-scratch.md`) and a project-namespace one
|
|
217
|
+
* (`project/<subdir>/<n>-<slug>.md`) without distinguishing them, since
|
|
218
|
+
* either shape is the same broken citation.
|
|
219
|
+
*/
|
|
220
|
+
const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*\/\d{3}-[\w-]+\.md\b/g
|
|
221
|
+
|
|
206
222
|
export interface ShippedReference {
|
|
207
223
|
readonly file: string
|
|
208
224
|
/** One-based, matching the `file:line` form a reader clicks. */
|
|
@@ -213,6 +229,7 @@ export interface ShippedReference {
|
|
|
213
229
|
| 'docs-path'
|
|
214
230
|
| 'standards-path'
|
|
215
231
|
| 'phase-label'
|
|
232
|
+
| 'rule-path'
|
|
216
233
|
/** The reference as written, so a report names the token to qualify. */
|
|
217
234
|
readonly text: string
|
|
218
235
|
/**
|
|
@@ -259,14 +276,19 @@ function isDocsPathReportable(
|
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
/**
|
|
262
|
-
* Whether `file` sits in the one corpus `STANDARDS_PATH`
|
|
279
|
+
* Whether `file` sits in the one corpus `STANDARDS_PATH` and `RULE_PATH`
|
|
280
|
+
* both gate: a shipped skill body, minus its own `REQUIREMENT.md`.
|
|
263
281
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
282
|
+
* Both patterns share this scope because both bans live in
|
|
283
|
+
* `598-authoring-layout.md`, stated for the same reader: a session loading
|
|
284
|
+
* the `SKILL.md` body a target actually receives. `REQUIREMENT.md` is
|
|
285
|
+
* excluded for the reason that file states there, since a maintainer or an
|
|
286
|
+
* audit command reads it rather than a session loading it, so neither
|
|
287
|
+
* resolver rule this pair enforces ever applies to it. One predicate serves
|
|
288
|
+
* both call sites rather than two copies drifting apart with nothing
|
|
289
|
+
* comparing them.
|
|
268
290
|
*/
|
|
269
|
-
function
|
|
291
|
+
function isSkillBodyScope(file: string): boolean {
|
|
270
292
|
return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
|
|
271
293
|
}
|
|
272
294
|
|
|
@@ -357,7 +379,7 @@ export function referencesIn(
|
|
|
357
379
|
})
|
|
358
380
|
}
|
|
359
381
|
|
|
360
|
-
if (
|
|
382
|
+
if (isSkillBodyScope(file)) {
|
|
361
383
|
for (const match of line.matchAll(STANDARDS_PATH)) {
|
|
362
384
|
if (!isStandardsPathReportable(match[0])) continue
|
|
363
385
|
references.push({
|
|
@@ -377,6 +399,18 @@ export function referencesIn(
|
|
|
377
399
|
text: match[0],
|
|
378
400
|
})
|
|
379
401
|
}
|
|
402
|
+
|
|
403
|
+
if (isSkillBodyScope(file)) {
|
|
404
|
+
for (const match of line.matchAll(RULE_PATH)) {
|
|
405
|
+
if (isPlaceholderPath(match[0])) continue
|
|
406
|
+
references.push({
|
|
407
|
+
file,
|
|
408
|
+
line: index + 1,
|
|
409
|
+
kind: 'rule-path',
|
|
410
|
+
text: match[0],
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
}
|
|
380
414
|
}
|
|
381
415
|
|
|
382
416
|
return references
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface HeadingProps {
|
|
5
|
+
readonly level: 1 | 2
|
|
6
|
+
readonly children: Child
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Heading({ level, children }: HeadingProps): Element {
|
|
10
|
+
return level === 1 ? <h1>{children}</h1> : <h2>{children}</h2>
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface ListProps {
|
|
5
|
+
readonly ordered?: boolean
|
|
6
|
+
readonly items: readonly Child[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Composes an item per entry rather than taking pre-built `<li>` children, so
|
|
11
|
+
* every list in a lesson escapes the same way regardless of what the caller
|
|
12
|
+
* hands in.
|
|
13
|
+
*/
|
|
14
|
+
export function List({ ordered, items }: ListProps): Element {
|
|
15
|
+
const rendered = items.map((item) => <li>{item}</li>)
|
|
16
|
+
return ordered ? <ol>{rendered}</ol> : <ul>{rendered}</ul>
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface ParagraphProps {
|
|
5
|
+
/** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
|
|
6
|
+
readonly lede?: boolean
|
|
7
|
+
readonly children: Child
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Paragraph({ lede, children }: ParagraphProps): Element {
|
|
11
|
+
return lede ? <p class="lede">{children}</p> : <p>{children}</p>
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun resolves this module rather than `jsx-runtime` at run time (spike 1,
|
|
3
|
+
* `.canon/groundwork/56-teach-render-layer/08-spikes.md`), so every export a
|
|
4
|
+
* caller might reach through either entry point has to exist here too.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
Fragment,
|
|
8
|
+
jsx,
|
|
9
|
+
jsx as jsxDEV,
|
|
10
|
+
jsxs,
|
|
11
|
+
render,
|
|
12
|
+
type JSX,
|
|
13
|
+
} from './jsx-runtime'
|