@erclx/canon 5.68.0 → 5.70.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/CHANGELOG.md +36 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +17 -6
- package/claude/skills/app-drive/references/drive-record.md +61 -8
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
- package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
- package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +3 -3
- package/docs/target/sync.md +23 -3
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +6 -4
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +183 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +8 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/init/steps.ts +2 -2
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +15 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/headless/run.ts +6 -3
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +100 -11
- package/src/take/film.ts +55 -4
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/pointer.ts +2 -1
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +141 -23
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- package/docs/target/migrations.md +0 -203
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
package/src/design/favicon.ts
CHANGED
|
@@ -1,37 +1,109 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface ThemePair {
|
|
2
2
|
readonly light: string
|
|
3
3
|
readonly dark: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
export interface FaviconColors {
|
|
7
|
+
/** The tile the mark sits on, dark wherever no theme applies. */
|
|
8
|
+
readonly tile: ThemePair
|
|
9
|
+
/** The tile's edge, drawn on light alone. */
|
|
10
|
+
readonly edge: string
|
|
11
|
+
/** The c, in the text ink of each theme. */
|
|
12
|
+
readonly c: ThemePair
|
|
13
|
+
/** The pip, in the brand pair rather than the page accent. */
|
|
14
|
+
readonly pip: ThemePair
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The mark's own colors, picked on the board and kept apart from the page accent tokens. */
|
|
7
18
|
export const FAVICON_COLORS: FaviconColors = {
|
|
8
|
-
light: '#
|
|
9
|
-
|
|
19
|
+
tile: { light: '#fbfaf8', dark: '#1b1a18' },
|
|
20
|
+
edge: '#dcdad6',
|
|
21
|
+
c: { light: '#2c2c29', dark: '#d9d7d4' },
|
|
22
|
+
pip: { light: '#c2380e', dark: '#ff6b42' },
|
|
10
23
|
}
|
|
11
24
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
/** The mark spans 96 of a 132 tile, with the tile's corner at 28% of its side. */
|
|
26
|
+
const MARK_IN_TILE = 96 / 132
|
|
27
|
+
const TILE_RADIUS = 0.28
|
|
28
|
+
/** About one pixel at a 32 pixel tab icon, drawn inside the tile's bounds. */
|
|
29
|
+
const EDGE_WIDTH = 4
|
|
30
|
+
|
|
31
|
+
/** The source's shapes, with its authoring comment and `currentColor` fills dropped. */
|
|
32
|
+
function markShapes(mark: string): string {
|
|
33
|
+
return mark
|
|
18
34
|
.replace(/<!--[\s\S]*?-->/, '')
|
|
19
35
|
.trim()
|
|
20
36
|
.replace(/^<svg[^>]*>/, '')
|
|
21
37
|
.replace(/<\/svg>$/, '')
|
|
22
38
|
.replaceAll(' fill="currentColor"', '')
|
|
23
39
|
.trim()
|
|
40
|
+
}
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
function markView(mark: string): [number, number, number, number] {
|
|
43
|
+
const view = /viewBox="([^"]+)"/
|
|
44
|
+
.exec(mark)?.[1]
|
|
45
|
+
.trim()
|
|
46
|
+
.split(/[\s,]+/)
|
|
47
|
+
const numbers = view?.map(Number) ?? []
|
|
48
|
+
if (numbers.length !== 4 || numbers.some((n) => !Number.isFinite(n))) {
|
|
49
|
+
throw new Error('the mark carries no viewBox of four numbers')
|
|
50
|
+
}
|
|
51
|
+
return numbers as [number, number, number, number]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const round = (n: number): number => Math.round(n * 100) / 100
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mark on its tile. The base rules draw the dark set, which a renderer
|
|
58
|
+
* ignoring the media query shows, and a light preference swaps in the light
|
|
59
|
+
* set. A browser reports light when no scheme is set, so a tab there shows the
|
|
60
|
+
* light tile. Every fill is a rule rather than an attribute so the light branch
|
|
61
|
+
* can flip them all.
|
|
62
|
+
*/
|
|
63
|
+
export function renderFavicon(mark: string, colors: FaviconColors): string {
|
|
64
|
+
const [x, y, width, height] = markView(mark)
|
|
65
|
+
const side = Math.max(width, height) / MARK_IN_TILE
|
|
66
|
+
const left = round(x + width / 2 - side / 2)
|
|
67
|
+
const top = round(y + height / 2 - side / 2)
|
|
68
|
+
const inset = EDGE_WIDTH / 2
|
|
69
|
+
const shapes = markShapes(mark)
|
|
70
|
+
|
|
71
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${left} ${top} ${round(side)} ${round(side)}">
|
|
26
72
|
<style>
|
|
27
|
-
|
|
28
|
-
|
|
73
|
+
.tile { fill: ${colors.tile.dark}; stroke: ${colors.tile.dark}; }
|
|
74
|
+
.c { fill: ${colors.c.dark}; }
|
|
75
|
+
.pip { fill: ${colors.pip.dark}; }
|
|
76
|
+
@media (prefers-color-scheme: light) {
|
|
77
|
+
.tile { fill: ${colors.tile.light}; stroke: ${colors.edge}; }
|
|
78
|
+
.c { fill: ${colors.c.light}; }
|
|
79
|
+
.pip { fill: ${colors.pip.light}; }
|
|
80
|
+
}
|
|
29
81
|
</style>
|
|
82
|
+
<rect class="tile" x="${round(left + inset)}" y="${round(top + inset)}" width="${round(side - EDGE_WIDTH)}" height="${round(side - EDGE_WIDTH)}" rx="${round(side * TILE_RADIUS - inset)}" stroke-width="${EDGE_WIDTH}" />
|
|
83
|
+
<g class="c">
|
|
30
84
|
${shapes.replace(/\n\s*/g, '\n ')}
|
|
85
|
+
</g>
|
|
31
86
|
</svg>
|
|
32
87
|
`
|
|
33
88
|
}
|
|
34
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The bare mark in fixed fills, for a surface that loads it as an image and
|
|
92
|
+
* so has neither a CSS context for `currentColor` nor a theme of its own.
|
|
93
|
+
*/
|
|
94
|
+
export function renderMark(
|
|
95
|
+
mark: string,
|
|
96
|
+
fills: { readonly c: string; readonly pip: string },
|
|
97
|
+
): string {
|
|
98
|
+
const svg = mark.replace(/<!--[\s\S]*?-->/, '').trim()
|
|
99
|
+
const pipFill = /(<[a-z]+ [^>]*class="pip"[^>]*?) fill="[^"]*"/
|
|
100
|
+
if (!pipFill.test(svg)) {
|
|
101
|
+
throw new Error('the mark carries no filled .pip shape')
|
|
102
|
+
}
|
|
103
|
+
const pip = svg.replace(pipFill, `$1 fill="${fills.pip}"`)
|
|
104
|
+
return `${pip.replaceAll('currentColor', fills.c)}\n`
|
|
105
|
+
}
|
|
106
|
+
|
|
35
107
|
/**
|
|
36
108
|
* The `<link rel="icon">` a page carries the rendered favicon in, inlined so a
|
|
37
109
|
* generated page needs no sibling file and no href that varies with its depth.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `scripts/core/design-sync.ts`, ported from Frameboard's
|
|
3
|
+
* own sync. Frameboard frames load a shared kit, while this board's frames
|
|
4
|
+
* load their project's `../assets/`, so each project's assets land under
|
|
5
|
+
* `kit/<project>/assets/` in `canon/design/`, mirroring the board, and no two
|
|
6
|
+
* projects can overwrite one another's files of the same name.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A `frames.json` entry the sync cannot capture, named so the file can be fixed. */
|
|
10
|
+
export class MappingError extends Error {}
|
|
11
|
+
|
|
12
|
+
const REFERENCE = /^[a-z0-9]+(-[a-z0-9]+)*\/[a-z0-9]+(-[a-z0-9]+)*$/
|
|
13
|
+
const BOARD_REF = /^[^/\s]+\/[^/\s]+\/[^/\s]+$/
|
|
14
|
+
// A link starts at a quote or `url(`, so `../../assets/` is left alone, and a
|
|
15
|
+
// file name stops at `#` or `?`, which address a part of the file.
|
|
16
|
+
const BOARD_LINK = /(["'(]\s*)\.\.\/assets\//g
|
|
17
|
+
const BOARD_ASSET = /["'(]\s*\.\.\/assets\/([^"'()\s#?]+)/g
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads `frames.json`, mapping each reference, `<group>/<name>`, to the
|
|
21
|
+
* board frame `<project>/<page>/<frame>` it is captured from. An entry in
|
|
22
|
+
* any other shape throws rather than filing into the wrong folder.
|
|
23
|
+
*/
|
|
24
|
+
export function parseFrameMap(json: string): Map<string, string> {
|
|
25
|
+
const parsed: unknown = JSON.parse(json)
|
|
26
|
+
const frames =
|
|
27
|
+
typeof parsed === 'object' && parsed !== null && 'frames' in parsed
|
|
28
|
+
? parsed.frames
|
|
29
|
+
: undefined
|
|
30
|
+
if (typeof frames !== 'object' || frames === null) {
|
|
31
|
+
throw new MappingError('frames.json needs a frames object.')
|
|
32
|
+
}
|
|
33
|
+
const map = new Map<string, string>()
|
|
34
|
+
for (const [reference, ref] of Object.entries(frames)) {
|
|
35
|
+
if (!REFERENCE.test(reference)) {
|
|
36
|
+
throw new MappingError(
|
|
37
|
+
`frames.json entry "${reference}" needs a kebab-case <group>/<name>.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (typeof ref !== 'string' || !BOARD_REF.test(ref)) {
|
|
41
|
+
throw new MappingError(
|
|
42
|
+
`frames.json entry "${reference}" needs a board frame as <project>/<page>/<frame>.`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
map.set(reference, ref)
|
|
46
|
+
}
|
|
47
|
+
return map
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The mapped board frames the board does not hold. */
|
|
51
|
+
export function missingFrames(
|
|
52
|
+
map: ReadonlyMap<string, string>,
|
|
53
|
+
board: ReadonlySet<string>,
|
|
54
|
+
): string[] {
|
|
55
|
+
return [...map.values()].filter((ref) => !board.has(ref))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the folder holds of a board frame: its project asset links moved to
|
|
60
|
+
* the project's kit, every other link left as the frame wrote it. The HTML
|
|
61
|
+
* is not reformatted, since the formatter ignores the folder and the sync is
|
|
62
|
+
* its one writer.
|
|
63
|
+
*/
|
|
64
|
+
export function toReference(html: string, project: string): string {
|
|
65
|
+
return html.replaceAll(BOARD_LINK, `$1../kit/${project}/assets/`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The project assets a frame loads, by their path under `assets/`, each once. */
|
|
69
|
+
export function boardAssets(html: string): string[] {
|
|
70
|
+
return [...new Set([...html.matchAll(BOARD_ASSET)].map((match) => match[1]))]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Drift {
|
|
74
|
+
readonly changed: readonly string[]
|
|
75
|
+
/** PNGs whose bytes differ, a warning since a PNG varies with the browser build. */
|
|
76
|
+
readonly pngDiffers: readonly string[]
|
|
77
|
+
readonly missing: readonly string[]
|
|
78
|
+
readonly stray: readonly string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compares what a sync would write against the files the folder holds, the HTML as the source and a PNG as derived. */
|
|
82
|
+
export function classifyDrift(
|
|
83
|
+
expected: ReadonlyMap<string, string | Uint8Array>,
|
|
84
|
+
tracked: ReadonlyMap<string, string | Uint8Array>,
|
|
85
|
+
): Drift {
|
|
86
|
+
const changed: string[] = []
|
|
87
|
+
const pngDiffers: string[] = []
|
|
88
|
+
const missing: string[] = []
|
|
89
|
+
for (const [path, data] of expected) {
|
|
90
|
+
const copy = tracked.get(path)
|
|
91
|
+
if (copy === undefined) missing.push(path)
|
|
92
|
+
else if (!Buffer.from(copy).equals(Buffer.from(data))) {
|
|
93
|
+
;(path.endsWith('.png') ? pngDiffers : changed).push(path)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const stray = [...tracked.keys()].filter((path) => !expected.has(path))
|
|
97
|
+
return { changed, pngDiffers, missing, stray }
|
|
98
|
+
}
|
package/src/design/render.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Cell, DesignDoc, Row } from '@/design/parse'
|
|
4
4
|
import { parseDesignDoc } from '@/design/parse'
|
|
5
5
|
import { fontFaceBlock } from '@/design/css'
|
|
6
|
+
import { faviconLink } from '@/design/favicon'
|
|
6
7
|
import type { FontFace } from '@/design/fonts'
|
|
7
8
|
import { FONT_FACES } from '@/design/fonts'
|
|
8
9
|
import { colorValue } from '@/design/tokens'
|
|
@@ -12,16 +13,6 @@ export interface RenderResult {
|
|
|
12
13
|
cssPath: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* The same path data as `assets/brand/mark.svg`, colored via `colorValue`
|
|
17
|
-
* since a data URI has no CSS context to resolve `currentColor` against and
|
|
18
|
-
* this page renders on the light chrome `previewChrome()` sets up.
|
|
19
|
-
*/
|
|
20
|
-
const FAVICON_COLOR = colorValue('light-accent') ?? '#a4471c'
|
|
21
|
-
const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
|
|
22
|
-
`<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="${FAVICON_COLOR}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${FAVICON_COLOR}" /></svg>`,
|
|
23
|
-
)}`
|
|
24
|
-
|
|
25
16
|
export interface RenderOptions {
|
|
26
17
|
/**
|
|
27
18
|
* Declare each vendored face the typography table names, and let the page
|
|
@@ -29,6 +20,11 @@ export interface RenderOptions {
|
|
|
29
20
|
* base64 block a target's render would otherwise grow by unasked.
|
|
30
21
|
*/
|
|
31
22
|
readonly embedFonts?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* A rendered favicon SVG to inline in the head. The caller reads and renders
|
|
25
|
+
* the mark, so this module reads no brand file a target does not carry.
|
|
26
|
+
*/
|
|
27
|
+
readonly favicon?: string
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
export function renderDesignDoc(
|
|
@@ -42,7 +38,10 @@ export function renderDesignDoc(
|
|
|
42
38
|
const htmlPath = join(outDir, 'index.html')
|
|
43
39
|
const faces = options.embedFonts ? namedFaces(doc) : []
|
|
44
40
|
writeFileSync(cssPath, buildCss(doc, faces))
|
|
45
|
-
writeFileSync(
|
|
41
|
+
writeFileSync(
|
|
42
|
+
htmlPath,
|
|
43
|
+
buildHtml(doc, bodyFamily(doc, faces), options.favicon),
|
|
44
|
+
)
|
|
46
45
|
return { htmlPath, cssPath }
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -218,7 +217,11 @@ function previewChrome(): string {
|
|
|
218
217
|
return [' :root {', ...lines, ' }'].join('\n')
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function buildHtml(
|
|
220
|
+
function buildHtml(
|
|
221
|
+
doc: DesignDoc,
|
|
222
|
+
bodyStack: string,
|
|
223
|
+
favicon: string | undefined,
|
|
224
|
+
): string {
|
|
222
225
|
const sections = [
|
|
223
226
|
sectionPersonality(doc.personality),
|
|
224
227
|
sectionColor(doc.color),
|
|
@@ -239,8 +242,7 @@ function buildHtml(doc: DesignDoc, bodyStack: string): string {
|
|
|
239
242
|
return `<!doctype html>
|
|
240
243
|
<html lang="en">
|
|
241
244
|
<head>
|
|
242
|
-
<meta charset="utf-8"
|
|
243
|
-
<link rel="icon" href="${FAVICON_HREF}">
|
|
245
|
+
<meta charset="utf-8">${favicon === undefined ? '' : `\n${faviconLink(favicon)}`}
|
|
244
246
|
<title>Design tokens</title>
|
|
245
247
|
<link rel="stylesheet" href="design.css">
|
|
246
248
|
<style>
|
package/src/design/tokens.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const REVIEWED = '2026-10-24'
|
|
|
100
100
|
|
|
101
101
|
export const TOKENS: DesignTokens = {
|
|
102
102
|
personality:
|
|
103
|
-
'Warm neutrals
|
|
103
|
+
'Warm neutrals with actions in ink, set in Geist. The page reads as prose, and monospace appears only where a surface shows a path, a command, or what a shell printed. The rust accent marks a status the surface it shows would color, and the landing page carries none, leaving the vermilion pip of the mark as its one hue.',
|
|
104
104
|
|
|
105
105
|
colorRules: [
|
|
106
106
|
'One accent. Never promote a second color into a structural role.',
|
|
@@ -155,7 +155,7 @@ export const TOKENS: DesignTokens = {
|
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
role: 'accent',
|
|
158
|
-
intent: '
|
|
158
|
+
intent: 'a status the surface would color, such as an open pull request',
|
|
159
159
|
value: '#c76b5f',
|
|
160
160
|
grounds: DARK_GROUNDS,
|
|
161
161
|
},
|
|
@@ -208,7 +208,7 @@ export const TOKENS: DesignTokens = {
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
role: 'light-accent',
|
|
211
|
-
intent: '
|
|
211
|
+
intent: 'a status the surface would color, on light',
|
|
212
212
|
value: '#ad4a4b',
|
|
213
213
|
grounds: LIGHT_GROUNDS,
|
|
214
214
|
},
|
|
@@ -361,6 +361,7 @@ export const TOKENS: DesignTokens = {
|
|
|
361
361
|
|
|
362
362
|
iconography: [
|
|
363
363
|
'No icon library. `assets/brand/mark.svg` is the one authored icon.',
|
|
364
|
+
'The mark takes its own colors from `src/design/favicon.ts`, never the accent: the c in the text ink and the pip in a vermilion pair.',
|
|
364
365
|
'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
|
|
365
366
|
],
|
|
366
367
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -248,6 +248,12 @@ export const STAGES: readonly Stage[] = [
|
|
|
248
248
|
failure:
|
|
249
249
|
'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
|
|
250
250
|
},
|
|
251
|
+
{
|
|
252
|
+
kind: 'drift',
|
|
253
|
+
pathspec: 'assets/brand/mark-color*.svg',
|
|
254
|
+
failure:
|
|
255
|
+
'The README mark pair drifted from the mark source. Run bun run check and commit assets/brand/mark-color.svg and assets/brand/mark-color-light.svg.',
|
|
256
|
+
},
|
|
251
257
|
{ kind: 'measure', measure: designProse },
|
|
252
258
|
],
|
|
253
259
|
success: 'Design source clean',
|
|
@@ -402,9 +408,9 @@ export const STAGES: readonly Stage[] = [
|
|
|
402
408
|
checks: [
|
|
403
409
|
{
|
|
404
410
|
kind: 'cli',
|
|
405
|
-
argv: ['gov', 'audit', '--gate'],
|
|
411
|
+
argv: ['gov', 'audit', '--gate', '--touched'],
|
|
406
412
|
failure:
|
|
407
|
-
'A rule in a gated folder is past a shape limit or short of its ledger. Run bun src/cli.ts gov audit.',
|
|
413
|
+
'A rule in a gated folder is past a shape limit or short of its ledger. Run bun src/cli.ts gov audit --touched.',
|
|
408
414
|
},
|
|
409
415
|
],
|
|
410
416
|
},
|
package/src/gov/audit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { join, normalize, relative, resolve, sep } from 'node:path'
|
|
4
4
|
import { listRuleFiles } from '@/gov/payload'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -57,6 +57,19 @@ export const PRACTICE_FOLDERS: readonly string[] = [
|
|
|
57
57
|
'tooling',
|
|
58
58
|
]
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The folders whose rules record house decisions. Their ledger is checked
|
|
62
|
+
* wherever one exists and owed only by a rule the branch touched, since the
|
|
63
|
+
* reasoning behind an older bullet was never written down and no backfill
|
|
64
|
+
* recovers it.
|
|
65
|
+
*/
|
|
66
|
+
export const CORE_FOLDERS: readonly string[] = [
|
|
67
|
+
'canon',
|
|
68
|
+
'claude',
|
|
69
|
+
'code',
|
|
70
|
+
'writing',
|
|
71
|
+
]
|
|
72
|
+
|
|
60
73
|
const SHIPPED_RULES = join('governance', 'rules')
|
|
61
74
|
const LEDGERS = join('governance', 'ledger')
|
|
62
75
|
|
|
@@ -316,29 +329,41 @@ export function compareLedger(
|
|
|
316
329
|
.filter((entry) => entry.bullets !== entry.rows)
|
|
317
330
|
}
|
|
318
331
|
|
|
319
|
-
/**
|
|
320
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Where a rule's ledger lives and whether its absence is a finding, or nothing
|
|
334
|
+
* when its folder carries none.
|
|
335
|
+
*/
|
|
336
|
+
function ledgerFor(
|
|
337
|
+
file: string,
|
|
338
|
+
touched: ReadonlySet<string>,
|
|
339
|
+
): { path: string; owed: boolean } | undefined {
|
|
321
340
|
const prefix = `${SHIPPED_RULES}${sep}`
|
|
322
341
|
if (!file.startsWith(prefix)) return undefined
|
|
323
342
|
const rest = file.slice(prefix.length)
|
|
324
343
|
const folder = rest.split(sep)[0]
|
|
325
|
-
|
|
326
|
-
return
|
|
344
|
+
const path = join(LEDGERS, rest)
|
|
345
|
+
if (PRACTICE_FOLDERS.includes(folder)) return { path, owed: true }
|
|
346
|
+
if (CORE_FOLDERS.includes(folder)) return { path, owed: touched.has(file) }
|
|
347
|
+
return undefined
|
|
327
348
|
}
|
|
328
349
|
|
|
329
350
|
async function readLedger(
|
|
330
351
|
root: string,
|
|
331
352
|
file: string,
|
|
332
353
|
groups: readonly HeadingCount[],
|
|
354
|
+
touched: ReadonlySet<string>,
|
|
333
355
|
): Promise<LedgerReading> {
|
|
334
|
-
const
|
|
335
|
-
if (
|
|
356
|
+
const ledger = ledgerFor(file, touched)
|
|
357
|
+
if (ledger === undefined) return { status: 'not-required' }
|
|
358
|
+
const { path } = ledger
|
|
336
359
|
|
|
337
360
|
let text: string
|
|
338
361
|
try {
|
|
339
362
|
text = await readFile(resolve(root, path), 'utf8')
|
|
340
363
|
} catch {
|
|
341
|
-
return
|
|
364
|
+
return ledger.owed
|
|
365
|
+
? { status: 'missing', path }
|
|
366
|
+
: { status: 'not-required' }
|
|
342
367
|
}
|
|
343
368
|
|
|
344
369
|
return {
|
|
@@ -352,14 +377,22 @@ async function readLedger(
|
|
|
352
377
|
* Measures every rule this tree authors, or the files named.
|
|
353
378
|
*
|
|
354
379
|
* A tree holding `governance/rules/` is a canon checkout, read across both
|
|
355
|
-
* authored corpora with a ledger asked of each outside-practice rule
|
|
356
|
-
*
|
|
380
|
+
* authored corpora with a ledger asked of each outside-practice rule and of
|
|
381
|
+
* each core rule in `touched`, and checked for any core rule carrying one.
|
|
382
|
+
* Any other tree is a target, read at `.claude/rules/project/` alone with no
|
|
357
383
|
* ledger asked, since ledgers never ship.
|
|
384
|
+
*
|
|
385
|
+
* `touched` holds paths relative to the root, and leaving it out reads no core
|
|
386
|
+
* rule as touched.
|
|
358
387
|
*/
|
|
359
388
|
export async function readRuleAudit(
|
|
360
389
|
root: string,
|
|
361
390
|
paths: readonly string[] = [],
|
|
391
|
+
options: { readonly touched?: readonly string[] } = {},
|
|
362
392
|
): Promise<RuleAuditReport> {
|
|
393
|
+
const touched = new Set(
|
|
394
|
+
(options.touched ?? []).map((path) => normalize(path)),
|
|
395
|
+
)
|
|
363
396
|
const scope = existsSync(resolve(root, SHIPPED_RULES)) ? 'canon' : 'target'
|
|
364
397
|
const corpus = scope === 'canon' ? CANON_CORPUS : TARGET_CORPUS
|
|
365
398
|
|
|
@@ -421,7 +454,11 @@ export async function readRuleAudit(
|
|
|
421
454
|
for (const abs of files) {
|
|
422
455
|
const file = relative(root, abs)
|
|
423
456
|
const { groups, ...shape } = measureRule(await readFile(abs, 'utf8'))
|
|
424
|
-
rules.push({
|
|
457
|
+
rules.push({
|
|
458
|
+
file,
|
|
459
|
+
...shape,
|
|
460
|
+
ledger: await readLedger(root, file, groups, touched),
|
|
461
|
+
})
|
|
425
462
|
}
|
|
426
463
|
|
|
427
464
|
return { kind: 'measured', scope, corpus, rules }
|
package/src/gov/detect.ts
CHANGED
|
@@ -253,7 +253,7 @@ function isLiteral(pattern: string): boolean {
|
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* A literal pattern is also tested on disk, since evidence such as
|
|
256
|
-
* `.canon/
|
|
256
|
+
* `.canon/board` sits in a folder a target's own `.gitignore` excludes, and
|
|
257
257
|
* the git listing never carries it.
|
|
258
258
|
*/
|
|
259
259
|
function matchEntry(
|
package/src/init/steps.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
|
|
|
7
7
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* States the same fact as `docs/
|
|
11
|
-
* own words. Keep the two in step by hand; nothing compares them.
|
|
10
|
+
* States the same fact as `docs/migrations/by-hand.md`'s backup section
|
|
11
|
+
* in its own words. Keep the two in step by hand; nothing compares them.
|
|
12
12
|
*/
|
|
13
13
|
const RECORDS_SETUP_NOTICE =
|
|
14
14
|
"No private repository to push to yet. Run 'canon records push' once one exists to print the one-time setup command, or --skip records to silence this."
|
package/src/markdown/scope.ts
CHANGED
|
@@ -136,8 +136,6 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
136
136
|
'claude/skills/build-in-slices/references/adopted.md',
|
|
137
137
|
'claude/skills/canon-cli/REQUIREMENT.md',
|
|
138
138
|
'claude/skills/canon-cli/SKILL.md',
|
|
139
|
-
'claude/skills/canon-feedback-triage/REQUIREMENT.md',
|
|
140
|
-
'claude/skills/canon-feedback-triage/SKILL.md',
|
|
141
139
|
'claude/skills/canon-feedback/REQUIREMENT.md',
|
|
142
140
|
'claude/skills/canon-feedback/SKILL.md',
|
|
143
141
|
'claude/skills/canon-feedback/references/template.md',
|
|
@@ -202,10 +200,10 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
202
200
|
'claude/skills/document-repair/REQUIREMENT.md',
|
|
203
201
|
'claude/skills/document-repair/SKILL.md',
|
|
204
202
|
'claude/skills/document-repair/references/batches.md',
|
|
205
|
-
'claude/skills/pick
|
|
206
|
-
'claude/skills/pick
|
|
207
|
-
'claude/skills/pick
|
|
208
|
-
'claude/skills/pick
|
|
203
|
+
'claude/skills/design-pick/REQUIREMENT.md',
|
|
204
|
+
'claude/skills/design-pick/SKILL.md',
|
|
205
|
+
'claude/skills/design-pick/references/board.md',
|
|
206
|
+
'claude/skills/design-pick/references/live-arms.md',
|
|
209
207
|
'claude/skills/write-doc/REQUIREMENT.md',
|
|
210
208
|
'claude/skills/write-doc/SKILL.md',
|
|
211
209
|
'claude/skills/write-doc/references/context.md',
|
|
@@ -229,10 +227,6 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
229
227
|
'claude/skills/design-identity/REQUIREMENT.md',
|
|
230
228
|
'claude/skills/design-identity/SKILL.md',
|
|
231
229
|
'claude/skills/design-identity/references/card-route.md',
|
|
232
|
-
'claude/skills/plan-ready/REQUIREMENT.md',
|
|
233
|
-
'claude/skills/plan-ready/SKILL.md',
|
|
234
|
-
'claude/skills/plan-ready/references/assembly.md',
|
|
235
|
-
'claude/skills/plan-ready/references/ready.md',
|
|
236
230
|
'claude/skills/git-branch/REQUIREMENT.md',
|
|
237
231
|
'claude/skills/git-branch/SKILL.md',
|
|
238
232
|
'claude/skills/git-branch/references/branch.md',
|
|
@@ -391,10 +385,10 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
391
385
|
'claude/skills/test-first/references/adopted.md',
|
|
392
386
|
'claude/skills/ui-checklist/REQUIREMENT.md',
|
|
393
387
|
'claude/skills/ui-checklist/SKILL.md',
|
|
394
|
-
'claude/skills/
|
|
395
|
-
'claude/skills/
|
|
396
|
-
'claude/skills/
|
|
397
|
-
'claude/skills/
|
|
388
|
+
'claude/skills/ui-audit/REQUIREMENT.md',
|
|
389
|
+
'claude/skills/ui-audit/SKILL.md',
|
|
390
|
+
'claude/skills/ui-measure/REQUIREMENT.md',
|
|
391
|
+
'claude/skills/ui-measure/SKILL.md',
|
|
398
392
|
'claude/skills/write-human/REQUIREMENT.md',
|
|
399
393
|
'claude/skills/write-human/SKILL.md',
|
|
400
394
|
'claude/skills/write-human/references/adopted.md',
|
|
@@ -404,6 +398,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
404
398
|
'claude/skills/youtube-transcripts/SKILL.md',
|
|
405
399
|
'.claude/skills/internal-claude/REQUIREMENT.md',
|
|
406
400
|
'.claude/skills/internal-docs/REQUIREMENT.md',
|
|
401
|
+
'.claude/skills/internal-feedback-triage/REQUIREMENT.md',
|
|
407
402
|
'.claude/skills/internal-governance/REQUIREMENT.md',
|
|
408
403
|
'.claude/skills/internal-rule-audit/REQUIREMENT.md',
|
|
409
404
|
'.claude/skills/internal-sandbox-check/REQUIREMENT.md',
|
|
@@ -88,8 +88,9 @@ const RENDER = [SCRATCH, 'render'] as const
|
|
|
88
88
|
* Every move this migration makes, as record-relative paths.
|
|
89
89
|
*
|
|
90
90
|
* The first two rows are batch 2's. The rest are batch 3's, in the order the
|
|
91
|
-
* intake's review-folder pass listed them
|
|
92
|
-
*
|
|
91
|
+
* intake's review-folder pass listed them, then the design board, moved to
|
|
92
|
+
* the `.canon/board` folder Frameboard names. A later batch appends here
|
|
93
|
+
* rather than adding a second table.
|
|
93
94
|
*/
|
|
94
95
|
export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
|
|
95
96
|
{ kind: 'folder', from: ['review', 'memory'], to: ['memory', 'review'] },
|
|
@@ -130,6 +131,7 @@ export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
|
|
|
130
131
|
picks: ['picks'],
|
|
131
132
|
evidence: ['evidence'],
|
|
132
133
|
},
|
|
134
|
+
{ kind: 'folder', from: ['canvas'], to: ['board'] },
|
|
133
135
|
]
|
|
134
136
|
|
|
135
137
|
function resolveRecord(root: string, segments: readonly string[]): string {
|
|
@@ -346,7 +348,7 @@ function entriesOf(dir: string, isDirectory: boolean): string[] {
|
|
|
346
348
|
}
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
|
-
* What `pick
|
|
351
|
+
* What `design-pick` Step 6 writes into the folder it archives a choice to:
|
|
350
352
|
* a capture per canvas frame, named `arm-<id>`. A design handoff marks a pick
|
|
351
353
|
* folder an earlier skill wrote. A folder carrying neither is evidence.
|
|
352
354
|
*/
|
|
@@ -54,6 +54,15 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
54
54
|
* target on the oldest spelling lands in one pass rather than two.
|
|
55
55
|
* `draft-ready` alone keys on its namespaced forms below rather than here.
|
|
56
56
|
*
|
|
57
|
+
* Four skills then moved into the family their job belongs to. `pick-by-eye`
|
|
58
|
+
* was the one step of the design flow without the `design-` prefix, so it
|
|
59
|
+
* became `design-pick`. `ui-record` filmed footage while "record" already
|
|
60
|
+
* named the design record, so it became `ui-film`. `ux-audit` and
|
|
61
|
+
* `ux-measure` were the only `ux-` skills beside the `ui-` ones, so they
|
|
62
|
+
* became `ui-audit` and `ui-measure`. `draft-and-pick`, `claude-ux-audit`,
|
|
63
|
+
* and `claude-ux-measure` are retargeted onto the new names for the same
|
|
64
|
+
* reason `claude-ui-test` was.
|
|
65
|
+
*
|
|
57
66
|
* Every name takes two words. Ten of these would have landed as a bare single
|
|
58
67
|
* word under a plain strip, and a bare word such as `review` or `docs` is a
|
|
59
68
|
* substring of ordinary prose with no token left for a later sweep to find.
|
|
@@ -92,8 +101,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
92
101
|
'claude-teach': 'teach-workspace',
|
|
93
102
|
'claude-ui-test': 'ui-checklist',
|
|
94
103
|
'ui-test': 'ui-checklist',
|
|
95
|
-
'claude-ux-audit': '
|
|
96
|
-
'claude-ux-measure': '
|
|
104
|
+
'claude-ux-audit': 'ui-audit',
|
|
105
|
+
'claude-ux-measure': 'ui-measure',
|
|
106
|
+
'ux-audit': 'ui-audit',
|
|
107
|
+
'ux-measure': 'ui-measure',
|
|
97
108
|
'claude-worker': 'role-worker',
|
|
98
109
|
'claude-worktree': 'session-worktree',
|
|
99
110
|
'canon-screencast': 'script-screencast',
|
|
@@ -110,8 +121,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
110
121
|
'draft-figure': 'draw-figure',
|
|
111
122
|
'draft-identity': 'design-identity',
|
|
112
123
|
'draft-slides': 'make-deck',
|
|
113
|
-
'draft-and-pick': 'pick
|
|
124
|
+
'draft-and-pick': 'design-pick',
|
|
114
125
|
'draft-screencast': 'script-screencast',
|
|
126
|
+
'pick-by-eye': 'design-pick',
|
|
127
|
+
'ui-record': 'ui-film',
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
/**
|
|
@@ -190,6 +203,10 @@ export const RENAMED_DRAFT_POINTERS: readonly string[] = [
|
|
|
190
203
|
* what shipped or what a session ran under whatever name was current then, so
|
|
191
204
|
* rewriting one makes it testify to a release or a run that never happened.
|
|
192
205
|
*
|
|
206
|
+
* A replay fixture is excluded on the same argument. It records a real
|
|
207
|
+
* project's tree as that project held it, so a rewrite changes what the replay
|
|
208
|
+
* proves.
|
|
209
|
+
*
|
|
193
210
|
* The record archives need no entry here. They are gitignored, so the tracked
|
|
194
211
|
* listing every sweep reads never reaches them.
|
|
195
212
|
*/
|
|
@@ -197,7 +214,7 @@ export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
|
|
|
197
214
|
replacements: { ...SKILL_NAME_MAP, ...SKILL_CITATION_MAP },
|
|
198
215
|
keepMarker: 'canon-keep-retired',
|
|
199
216
|
wholeToken: true,
|
|
200
|
-
excludedPrefixes: ['scripts/eval/result-'],
|
|
217
|
+
excludedPrefixes: ['scripts/eval/result-', 'sandbox/fixtures/replay/'],
|
|
201
218
|
excludedPaths: [
|
|
202
219
|
'CHANGELOG.md',
|
|
203
220
|
'src/migrate/skill-names.ts',
|
package/src/migrate/status.ts
CHANGED
|
@@ -8,9 +8,9 @@ interface Migration {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every `canon migrate` subcommand in the order a project runs them, records
|
|
11
|
-
* first. This is the source for that order, and
|
|
12
|
-
* cites the verb rather than keeping a
|
|
13
|
-
* one line here.
|
|
11
|
+
* first. This is the source for that order, and
|
|
12
|
+
* `docs/migrations/by-verb.md` cites the verb rather than keeping a
|
|
13
|
+
* second list. A new migration joins by one line here.
|
|
14
14
|
*/
|
|
15
15
|
export const MIGRATIONS: readonly Migration[] = [
|
|
16
16
|
{ name: 'records', flags: [] },
|