@erclx/canon 5.26.0 → 5.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +2 -0
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +6 -6
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/SKILL.md +3 -7
  63. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  64. package/claude/skills/review-branch/SKILL.md +2 -0
  65. package/claude/skills/review-craft/SKILL.md +2 -0
  66. package/claude/skills/review-craft/references/ui.md +2 -2
  67. package/claude/skills/review-pr/SKILL.md +4 -8
  68. package/claude/skills/review-pr/references/body-variants.md +52 -0
  69. package/claude/skills/review-pr/references/key-changes.md +16 -0
  70. package/claude/skills/review-pr/references/post.md +15 -43
  71. package/claude/skills/review-ui/SKILL.md +3 -1
  72. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  73. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  74. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  75. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  76. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  77. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  78. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  80. package/claude/skills/role-planner/SKILL.md +2 -0
  81. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  82. package/claude/skills/role-reviewer/SKILL.md +2 -0
  83. package/claude/skills/role-worker/SKILL.md +2 -0
  84. package/claude/skills/search-craft/SKILL.md +2 -0
  85. package/claude/skills/seed-sync/SKILL.md +2 -0
  86. package/claude/skills/session-compact/SKILL.md +2 -0
  87. package/claude/skills/session-map/SKILL.md +2 -0
  88. package/claude/skills/session-relay/SKILL.md +2 -0
  89. package/claude/skills/session-resume/SKILL.md +2 -0
  90. package/claude/skills/session-worktree/SKILL.md +3 -11
  91. package/claude/skills/session-worktree/references/install-command.md +20 -0
  92. package/claude/skills/sketch-design/SKILL.md +2 -0
  93. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  94. package/claude/skills/target-check/SKILL.md +2 -0
  95. package/claude/skills/target-setup/SKILL.md +2 -0
  96. package/claude/skills/task-board/SKILL.md +2 -0
  97. package/claude/skills/teach-workspace/SKILL.md +5 -15
  98. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  99. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  100. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  101. package/claude/skills/test-craft/SKILL.md +2 -0
  102. package/claude/skills/test-first/SKILL.md +2 -0
  103. package/claude/skills/ui-checklist/SKILL.md +2 -0
  104. package/claude/skills/ux-audit/SKILL.md +2 -0
  105. package/claude/skills/ux-measure/SKILL.md +2 -0
  106. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  107. package/claude/skills/video-craft/SKILL.md +2 -0
  108. package/claude/skills/write-human/SKILL.md +2 -0
  109. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  110. package/docs/agents/canvas.md +23 -7
  111. package/docs/agents/capture.md +5 -5
  112. package/docs/agents/commands.md +2 -2
  113. package/docs/agents/index.md +3 -3
  114. package/docs/agents/install-and-sync.md +7 -0
  115. package/docs/agents/key-changes.md +1 -1
  116. package/docs/agents/pr-evidence.md +38 -15
  117. package/docs/agents/pr-local.md +19 -13
  118. package/docs/agents/pr-preview.md +44 -21
  119. package/docs/agents/sandbox.md +28 -4
  120. package/docs/agents/scripting.md +8 -1
  121. package/docs/agents/skills-audit.md +2 -1
  122. package/docs/workflow/operating-model.md +5 -6
  123. package/docs/workflow/parallel-features.md +22 -1
  124. package/docs/workflow/skill-map.md +3 -1
  125. package/docs/workflow/zshrc-aliases.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/core/regen-hero.ts +1 -1
  128. package/scripts/lib/sandbox-path.sh +6 -162
  129. package/src/audits/run.ts +1 -1
  130. package/src/canvas/address.ts +12 -3
  131. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  132. package/src/canvas/client/inspector/color-field.tsx +201 -171
  133. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  134. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  135. package/src/canvas/client/inspector/color-space.ts +240 -0
  136. package/src/canvas/client/inspector/field.tsx +5 -1
  137. package/src/canvas/client/inspector/fields.css +395 -6
  138. package/src/canvas/client/inspector/size-field.tsx +69 -0
  139. package/src/canvas/client/inspector/values.ts +43 -1
  140. package/src/canvas/client/inspector.tsx +190 -4
  141. package/src/canvas/client/selection.tsx +197 -0
  142. package/src/canvas/client/shell.css +104 -12
  143. package/src/canvas/client/state.ts +69 -9
  144. package/src/canvas/client/surface.tsx +147 -22
  145. package/src/canvas/content.ts +50 -1
  146. package/src/canvas/edit.ts +37 -4
  147. package/src/canvas/editing.ts +203 -0
  148. package/src/canvas/global.d.ts +15 -0
  149. package/src/canvas/server.ts +149 -12
  150. package/src/canvas/tokens.ts +59 -17
  151. package/src/census/count.ts +1 -1
  152. package/src/claude/seeds.ts +1 -1
  153. package/src/claude/skills-audit.ts +28 -0
  154. package/src/claude/skills-drift.ts +1 -1
  155. package/src/claude/skills-families.ts +36 -0
  156. package/src/claude/skills-list.ts +76 -15
  157. package/src/commands/audits.ts +2 -2
  158. package/src/commands/canvas.ts +179 -2
  159. package/src/commands/claude.ts +67 -9
  160. package/src/commands/design.ts +142 -2
  161. package/src/commands/gov.ts +1 -1
  162. package/src/commands/hooks.ts +1 -1
  163. package/src/commands/indexes.ts +1 -1
  164. package/src/commands/init.ts +1 -1
  165. package/src/commands/intake.ts +1 -1
  166. package/src/commands/migrate.ts +1 -1
  167. package/src/commands/pr.ts +105 -58
  168. package/src/commands/records.ts +1 -1
  169. package/src/commands/repo.ts +1 -1
  170. package/src/commands/sandbox.ts +54 -2
  171. package/src/commands/sessions.ts +1 -1
  172. package/src/commands/sync.ts +1 -1
  173. package/src/commands/tasks.ts +2 -2
  174. package/src/commands/teach.ts +1 -1
  175. package/src/commands/transcripts.ts +1 -1
  176. package/src/commands/worktrees.ts +1 -1
  177. package/src/comments/scan.ts +1 -1
  178. package/src/comments/trend.ts +1 -1
  179. package/src/context/citations.ts +1 -1
  180. package/src/context/classify/extract.ts +2 -2
  181. package/src/design/project-fonts.ts +392 -0
  182. package/src/feedback/github.ts +1 -1
  183. package/src/gate/measures.ts +47 -9
  184. package/src/gate/sequencer.ts +1 -1
  185. package/src/gate/stages.ts +9 -0
  186. package/src/{git-files.ts → git/files.ts} +2 -2
  187. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  188. package/src/{worktree.ts → git/worktree.ts} +1 -1
  189. package/src/gov/citations.ts +1 -1
  190. package/src/gov/install.ts +1 -1
  191. package/src/gov/superseded.ts +1 -1
  192. package/src/gov/test-order.ts +2 -2
  193. package/src/help.ts +1 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/indexes/walk.ts +1 -1
  196. package/src/labels/audit.ts +1 -1
  197. package/src/markdown/files.ts +1 -1
  198. package/src/migrate/apply.ts +1 -1
  199. package/src/pr/bijection.ts +1 -1
  200. package/src/pr/evidence.ts +96 -43
  201. package/src/pr/local.ts +16 -10
  202. package/src/pr/preview.ts +78 -2
  203. package/src/records/backup.ts +1 -1
  204. package/src/records/stale-canonical.ts +1 -1
  205. package/src/sandbox/headless/dispatch.ts +264 -0
  206. package/src/sandbox/headless/run.ts +653 -0
  207. package/src/sandbox/headless/watch.ts +145 -0
  208. package/src/sandbox/provision.ts +12 -1
  209. package/src/sandbox/tree.ts +4 -8
  210. package/src/secrets/scan.ts +1 -1
  211. package/src/sessions/claim.ts +1 -1
  212. package/src/sessions/resolve.ts +1 -1
  213. package/src/sessions/transfer/bundle.ts +1 -1
  214. package/src/slides/package/fonts.ts +6 -2
  215. package/src/standards/audit.ts +2 -2
  216. package/src/sync/check.ts +2 -2
  217. package/src/sync/engine.ts +2 -2
  218. package/src/sync/git.ts +1 -1
  219. package/src/sync/history.ts +1 -1
  220. package/src/sync/reverse.ts +1 -1
  221. package/src/sync/target.ts +1 -1
  222. package/src/sync/workflow.ts +1 -1
  223. package/src/targets/pulls.ts +2 -2
  224. package/src/targets/sweep.ts +2 -2
  225. package/src/tasks/reach.ts +1 -1
  226. package/src/tasks/trunk.ts +1 -1
  227. package/src/tooling/inject.ts +1 -1
  228. package/src/tooling/subfolder.ts +1 -1
  229. package/src/web/readme-citations.ts +1 -1
  230. package/src/worktrees/bare-flag.ts +1 -1
  231. package/src/worktrees/reclaim.ts +2 -2
  232. package/src/worktrees/remove.ts +1 -1
  233. package/standards/canvas.md +2 -1
  234. package/standards/skill.md +1 -0
  235. package/standards/wiki.md +12 -3
  236. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  237. package/scripts/lib/sandbox-dispatch.sh +0 -190
  238. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  239. /package/src/{git-base.ts → git/base.ts} +0 -0
  240. /package/src/{git-env.ts → git/env.ts} +0 -0
  241. /package/src/{copy.ts → sync/copy.ts} +0 -0
  242. /package/src/{target.ts → targets/validate.ts} +0 -0
@@ -0,0 +1,203 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import {
4
+ type ContentRefused,
5
+ canvasDir,
6
+ isValidName,
7
+ readPage,
8
+ TEMP_SUFFIX,
9
+ withFileLock,
10
+ } from '@/canvas/content'
11
+
12
+ /**
13
+ * Which frames a session says it is editing, beside `selection.json`. The
14
+ * server cannot see a write until it lands, so the mark is a record a session
15
+ * writes rather than something inferred, and it gates no edit.
16
+ */
17
+ export const EDITING_FILE = 'editing.json'
18
+
19
+ /**
20
+ * How long a mark lives unrenewed. A session that crashes or compacts never
21
+ * clears its mark, so this is how long a dead one can mislead the operator.
22
+ */
23
+ export const EDITING_TTL_MS = 5 * 60_000
24
+
25
+ export interface EditingMark {
26
+ readonly page: string
27
+ readonly frame: string
28
+ readonly by: string
29
+ readonly since: string
30
+ readonly until: string
31
+ }
32
+
33
+ type Stored = Omit<EditingMark, 'page' | 'frame'>
34
+
35
+ export type MarkOutcome =
36
+ | { readonly ok: true; readonly mark: EditingMark }
37
+ | ContentRefused
38
+
39
+ export type ClearOutcome =
40
+ | { readonly ok: true; readonly cleared: boolean }
41
+ | ContentRefused
42
+
43
+ function recordPath(root: string): string {
44
+ return join(canvasDir(root), EDITING_FILE)
45
+ }
46
+
47
+ function isStored(value: unknown): value is Stored {
48
+ if (typeof value !== 'object' || value === null) return false
49
+ const { by, since, until } = value as Record<string, unknown>
50
+ return (
51
+ typeof by === 'string' &&
52
+ typeof since === 'string' &&
53
+ typeof until === 'string' &&
54
+ !Number.isNaN(Date.parse(until))
55
+ )
56
+ }
57
+
58
+ /** Every well-formed entry, with a malformed file read as holding none. */
59
+ function readStored(root: string): Map<string, Stored> {
60
+ const entries = new Map<string, Stored>()
61
+ let parsed: unknown
62
+ try {
63
+ parsed = JSON.parse(readFileSync(recordPath(root), 'utf8'))
64
+ } catch {
65
+ return entries
66
+ }
67
+ if (typeof parsed !== 'object' || parsed === null) return entries
68
+ for (const [key, value] of Object.entries(parsed)) {
69
+ if (isStored(value)) entries.set(key, value)
70
+ }
71
+ return entries
72
+ }
73
+
74
+ function toMark(key: string, stored: Stored): EditingMark | undefined {
75
+ const [page, frame, ...rest] = key.split('/')
76
+ if (!page || !frame || rest.length > 0) return undefined
77
+ return { page, frame, ...stored }
78
+ }
79
+
80
+ /** A mark still inside its expiry, on a frame still on disk. */
81
+ function isLive(root: string, mark: EditingMark, now: Date): boolean {
82
+ if (Date.parse(mark.until) <= now.getTime()) return false
83
+ return (
84
+ readPage(root, mark.page)?.frames.some(
85
+ (candidate) => candidate.name === mark.frame,
86
+ ) ?? false
87
+ )
88
+ }
89
+
90
+ function liveMarks(
91
+ root: string,
92
+ entries: ReadonlyMap<string, Stored>,
93
+ now: Date,
94
+ ): EditingMark[] {
95
+ return [...entries]
96
+ .flatMap(([key, stored]) => toMark(key, stored) ?? [])
97
+ .filter((mark) => isLive(root, mark, now))
98
+ .sort((a, b) =>
99
+ `${a.page}/${a.frame}`.localeCompare(`${b.page}/${b.frame}`),
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Replaces the record through a rename, keeping only live marks, so the file
105
+ * never grows with marks nobody cleared. The caller holds the lock, which is
106
+ * what keeps two sessions marking at once from dropping each other's key.
107
+ */
108
+ function writeMarks(root: string, marks: readonly EditingMark[]): void {
109
+ const record = Object.fromEntries(
110
+ marks.map(({ page, frame, ...stored }) => [`${page}/${frame}`, stored]),
111
+ )
112
+ const target = recordPath(root)
113
+ const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
114
+ writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`)
115
+ renameSync(temp, target)
116
+ }
117
+
118
+ export function markEditing(
119
+ root: string,
120
+ page: string,
121
+ frame: string,
122
+ by: string,
123
+ now: Date,
124
+ ): MarkOutcome {
125
+ if (!isValidName(page) || !isValidName(frame)) {
126
+ return {
127
+ ok: false,
128
+ reason: 'invalid-name',
129
+ detail: `${page}/${frame} is not a valid frame`,
130
+ }
131
+ }
132
+ const onPage = readPage(root, page)
133
+ if (!onPage) {
134
+ return {
135
+ ok: false,
136
+ reason: 'no-page',
137
+ detail: `page ${page} does not exist`,
138
+ }
139
+ }
140
+ if (!onPage.frames.some((candidate) => candidate.name === frame)) {
141
+ return {
142
+ ok: false,
143
+ reason: 'no-frame',
144
+ detail: `frame ${frame} does not exist on ${page}`,
145
+ }
146
+ }
147
+
148
+ const mark: EditingMark = {
149
+ page,
150
+ frame,
151
+ by,
152
+ since: now.toISOString(),
153
+ until: new Date(now.getTime() + EDITING_TTL_MS).toISOString(),
154
+ }
155
+ return withFileLock(recordPath(root), (): MarkOutcome => {
156
+ const entries = readStored(root)
157
+ entries.set(`${page}/${frame}`, {
158
+ by,
159
+ since: mark.since,
160
+ until: mark.until,
161
+ })
162
+ writeMarks(root, liveMarks(root, entries, now))
163
+ return { ok: true, mark }
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Drops one frame's mark. Clearing a frame that holds none succeeds and writes
169
+ * nothing, so a session clearing defensively never fails, and a frame removed
170
+ * while marked still clears.
171
+ */
172
+ export function clearEditing(
173
+ root: string,
174
+ page: string,
175
+ frame: string,
176
+ ): ClearOutcome {
177
+ if (!isValidName(page) || !isValidName(frame)) {
178
+ return {
179
+ ok: false,
180
+ reason: 'invalid-name',
181
+ detail: `${page}/${frame} is not a valid frame`,
182
+ }
183
+ }
184
+ if (!existsSync(recordPath(root))) return { ok: true, cleared: false }
185
+
186
+ return withFileLock(recordPath(root), (): ClearOutcome => {
187
+ const entries = readStored(root)
188
+ if (!entries.delete(`${page}/${frame}`)) return { ok: true, cleared: false }
189
+ writeMarks(
190
+ root,
191
+ [...entries].flatMap(([key, stored]) => toMark(key, stored) ?? []),
192
+ )
193
+ return { ok: true, cleared: true }
194
+ })
195
+ }
196
+
197
+ /**
198
+ * The live marks, sorted by frame. A mark past its expiry or on a frame since
199
+ * removed reads as absent with no write, so a reader never gets a dead name.
200
+ */
201
+ export function readEditing(root: string, now: Date): EditingMark[] {
202
+ return liveMarks(root, readStored(root), now)
203
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The screen color sampler, which only Chromium ships. TypeScript's DOM
3
+ * library carries no type for it, so a window without it reads as undefined.
4
+ */
5
+ interface EyeDropperResult {
6
+ readonly sRGBHex: string
7
+ }
8
+
9
+ interface EyeDropperSampler {
10
+ open(options?: { readonly signal?: AbortSignal }): Promise<EyeDropperResult>
11
+ }
12
+
13
+ interface Window {
14
+ readonly EyeDropper?: new () => EyeDropperSampler
15
+ }
@@ -1,7 +1,9 @@
1
1
  import { type FSWatcher, mkdirSync, watch } from 'node:fs'
2
2
  import {
3
+ countElements,
3
4
  type ElementAddress,
4
5
  HASH_ATTRIBUTE,
6
+ IMPLIED_ATTRIBUTE,
5
7
  TOKENS_ATTRIBUTE,
6
8
  } from '@/canvas/address'
7
9
  import {
@@ -12,10 +14,12 @@ import {
12
14
  LOCK_SUFFIX,
13
15
  moveFrame,
14
16
  readSelection,
17
+ resizeFrame,
15
18
  TEMP_SUFFIX,
16
19
  writeSelection,
17
20
  } from '@/canvas/content'
18
21
  import { type EditRefused, editFrame } from '@/canvas/edit'
22
+ import { readEditing } from '@/canvas/editing'
19
23
  import {
20
24
  resolveFrameTokens,
21
25
  type TokenOptions,
@@ -90,24 +94,125 @@ function styleElement(css: string, hash: string | undefined): string {
90
94
  return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
91
95
  }
92
96
 
97
+ /** The wrappers a browser builds whether or not the file states them. */
98
+ const WRAPPERS = new Set(['html', 'head', 'body'])
99
+
100
+ /** Which of the document wrappers the file states, counted as the address counts. */
101
+ function statedWrappers(html: string): Set<string> {
102
+ const stated = new Set<string>()
103
+ countElements(new HTMLRewriter(), (element) => {
104
+ const tag = element.tagName.toLowerCase()
105
+ if (WRAPPERS.has(tag)) stated.add(tag)
106
+ return undefined
107
+ }).transform(html)
108
+ return stated
109
+ }
110
+
111
+ /** Prepends markup inside the first element a selector matches. */
112
+ function prependFirst(html: string, selector: string, markup: string): string {
113
+ let isDone = false
114
+ return new HTMLRewriter()
115
+ .on(selector, {
116
+ element(element) {
117
+ if (isDone) return
118
+ isDone = true
119
+ element.prepend(markup, { html: true })
120
+ },
121
+ })
122
+ .transform(html)
123
+ }
124
+
125
+ /**
126
+ * What a browser parses into the head it builds ahead of a fragment's first
127
+ * content. A comment or whitespace among them stays in that head too.
128
+ */
129
+ const HEAD_CONTENT = new Set([
130
+ 'base',
131
+ 'link',
132
+ 'meta',
133
+ 'noscript',
134
+ 'script',
135
+ 'style',
136
+ 'template',
137
+ 'title',
138
+ ])
139
+
140
+ /**
141
+ * Opens a marked body where the browser would build its own: ahead of the
142
+ * first element or text outside the head content a file leads with, or at the
143
+ * end when the file holds nothing else.
144
+ */
145
+ function openBodyBeforeContent(html: string, marker: string): string {
146
+ let isOpen = false
147
+ let headDepth = 0
148
+ const open = (target: HTMLRewriterTypes.Element | HTMLRewriterTypes.Text) => {
149
+ isOpen = true
150
+ target.before(marker, { html: true })
151
+ }
152
+ return new HTMLRewriter()
153
+ .on('*', {
154
+ element(element) {
155
+ if (isOpen || headDepth > 0) return
156
+ const tag = element.tagName.toLowerCase()
157
+ if (tag === 'html') return
158
+ if (!HEAD_CONTENT.has(tag)) {
159
+ open(element)
160
+ return
161
+ }
162
+ if (!element.canHaveContent || element.selfClosing) return
163
+ headDepth += 1
164
+ element.onEndTag(() => {
165
+ headDepth -= 1
166
+ })
167
+ },
168
+ })
169
+ .onDocument({
170
+ text(chunk) {
171
+ if (!isOpen && headDepth === 0 && chunk.text.trim() !== '') open(chunk)
172
+ },
173
+ end(end) {
174
+ if (!isOpen) end.append(marker, { html: true })
175
+ },
176
+ })
177
+ .transform(html)
178
+ }
179
+
93
180
  /**
94
181
  * Puts the tokens first in the head, so a stylesheet the frame links itself
95
- * still wins the cascade. A frame with no head gets the element right after
96
- * its doctype, which a browser hoists into the head it builds. A hash stamps
97
- * the element even when no tokens resolve, since the shell sends it back with
98
- * an element pick.
182
+ * still wins the cascade. A hash stamps the element even when no tokens
183
+ * resolve, since the shell sends it back with an element pick.
184
+ *
185
+ * A wrapper the file leaves out is written in marked, since the browser would
186
+ * build it anyway and the shell's count would run past the file's. An `html`
187
+ * goes right after the doctype, which keeps the frame out of quirks mode. A
188
+ * marked `head` is left open, so the head content a fragment leads with parses
189
+ * into it the way it would into the head a browser builds, and a file stating
190
+ * neither `head` nor `body` gets a marked body opened where that content ends.
191
+ * A file stating a `head` but no `body` is left to the browser.
99
192
  */
100
193
  export function injectTokens(html: string, css: string, hash?: string): string {
101
194
  if (css === '' && hash === undefined) return html
102
- const element = styleElement(css, hash)
103
- const head = html.match(/<head(?:\s[^>]*)?>/i)
104
- if (head?.index === undefined) {
105
- /* Ahead of a doctype the element would drop the frame into quirks mode. */
106
- const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
107
- return `${doctype}${element}${html.slice(doctype.length)}`
195
+ const style = styleElement(css, hash)
196
+ const stated = statedWrappers(html)
197
+ const isHeadStated = stated.has('head')
198
+ const withBody =
199
+ isHeadStated || stated.has('body')
200
+ ? html
201
+ : openBodyBeforeContent(html, `<body ${IMPLIED_ATTRIBUTE}>`)
202
+ if (isHeadStated) {
203
+ const withHead = prependFirst(withBody, 'head', style)
204
+ return stated.has('html') ? withHead : markedHtml(withHead, '')
108
205
  }
109
- const at = head.index + head[0].length
110
- return `${html.slice(0, at)}${element}${html.slice(at)}`
206
+ const head = `<head ${IMPLIED_ATTRIBUTE}>${style}`
207
+ return stated.has('html')
208
+ ? prependFirst(withBody, 'html', head)
209
+ : markedHtml(withBody, head)
210
+ }
211
+
212
+ /** Writes a marked `html` and what follows it right after the doctype. */
213
+ function markedHtml(html: string, lead: string): string {
214
+ const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
215
+ return `${doctype}<html ${IMPLIED_ATTRIBUTE}>${lead}${html.slice(doctype.length)}`
111
216
  }
112
217
 
113
218
  /**
@@ -348,6 +453,11 @@ export function startCanvas(
348
453
  hostname: SERVE_HOST,
349
454
  port,
350
455
  development: false,
456
+ /*
457
+ * Production mode turns port reuse on, so a second canvas would bind
458
+ * beside one already listening rather than walk past it.
459
+ */
460
+ reusePort: false,
351
461
  /* The change stream stays open for as long as the shell does. */
352
462
  idleTimeout: 0,
353
463
  routes: {
@@ -374,6 +484,9 @@ export function startCanvas(
374
484
  },
375
485
  }),
376
486
  },
487
+ editing: readEditing(root, new Date()).map(
488
+ ({ page, frame, by, until }) => ({ page, frame, by, until }),
489
+ ),
377
490
  })
378
491
  }),
379
492
  '/api/frames/move': {
@@ -396,6 +509,30 @@ export function startCanvas(
396
509
  }),
397
510
  ),
398
511
  },
512
+ '/api/frames/resize': {
513
+ POST: guarded(
514
+ mutation((body) => {
515
+ if (
516
+ !isRecord(body) ||
517
+ typeof body.page !== 'string' ||
518
+ typeof body.frame !== 'string' ||
519
+ typeof body.x !== 'number' ||
520
+ typeof body.y !== 'number' ||
521
+ typeof body.width !== 'number' ||
522
+ typeof body.height !== 'number'
523
+ ) {
524
+ return badBody('send page, frame, x, y, width, and height')
525
+ }
526
+ const outcome = resizeFrame(root, body.page, body.frame, {
527
+ x: body.x,
528
+ y: body.y,
529
+ width: body.width,
530
+ height: body.height,
531
+ })
532
+ return outcome.ok ? json(outcome) : refusal(outcome)
533
+ }),
534
+ ),
535
+ },
399
536
  '/api/frames/edit': {
400
537
  POST: guarded(
401
538
  mutation((body) => {
@@ -1,7 +1,18 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
4
- import { buildDesignCss } from '@/design/css'
4
+ import { buildDesignCss, fontFaceBlock } from '@/design/css'
5
+ import {
6
+ FONT_FACES,
7
+ HAND_DRAWN_FONT_FACES,
8
+ type FontFace,
9
+ } from '@/design/fonts'
10
+ import {
11
+ type DroppedFace,
12
+ inlineProjectFaces,
13
+ namedFamilies,
14
+ projectSheets,
15
+ } from '@/design/project-fonts'
5
16
  import { isOwnCheckout } from '@/project-root'
6
17
 
7
18
  /**
@@ -17,6 +28,8 @@ export type FrameTokens =
17
28
  readonly css: string
18
29
  /** Relative to the root, in the order the stylesheet concatenates them. */
19
30
  readonly files: readonly string[]
31
+ /** Project faces left out of the sheet, present only when one was. */
32
+ readonly dropped?: readonly DroppedFace[]
20
33
  }
21
34
  | { readonly source: 'none'; readonly css: ''; readonly notice: string }
22
35
 
@@ -113,19 +126,44 @@ export function tokenGroups(css: string): TokenGroup[] {
113
126
 
114
127
  /** The installed files in cascade order: the base first, then each override. */
115
128
  function installedFiles(root: string): string[] {
116
- const files: string[] = []
117
129
  const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
118
- if (existsSync(join(root, base))) files.push(base)
130
+ return [
131
+ ...(existsSync(join(root, base)) ? [base] : []),
132
+ ...projectSheets(root),
133
+ ]
134
+ }
135
+
136
+ /**
137
+ * The vendored faces the installed sheets name and no project rule already
138
+ * declares, so a target renders the base's families on a machine that never
139
+ * installed them, and a project face of the same family wins outright.
140
+ */
141
+ function toolkitFaces(css: string, declared: ReadonlySet<string>): FontFace[] {
142
+ const named = namedFamilies(css)
143
+ return [...FONT_FACES, ...HAND_DRAWN_FONT_FACES].filter(
144
+ (face) => named.has(face.family) && !declared.has(face.family),
145
+ )
146
+ }
119
147
 
148
+ /**
149
+ * A project sheet with its local `url()`s inlined, since a frame is served
150
+ * from the content folder and cannot reach a path under `.claude/design/`.
151
+ */
152
+ function installedSheets(root: string, files: readonly string[]) {
120
153
  const projectDir = join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
121
- if (existsSync(join(root, projectDir))) {
122
- const overrides = readdirSync(join(root, projectDir))
123
- .filter((name) => name.endsWith('.css'))
124
- .sort((a, b) => a.localeCompare(b))
125
- .map((name) => join(projectDir, name))
126
- files.push(...overrides)
127
- }
128
- return files
154
+ const dropped: DroppedFace[] = []
155
+ const declared = new Set<string>()
156
+ const css = files
157
+ .map((file) => {
158
+ const raw = readFileSync(join(root, file), 'utf8')
159
+ if (!file.startsWith(projectDir)) return `/* ${file} */\n${raw}`
160
+ const inlined = inlineProjectFaces(root, file, raw)
161
+ dropped.push(...inlined.dropped)
162
+ for (const family of inlined.declared) declared.add(family)
163
+ return `/* ${file} */\n${inlined.css}`
164
+ })
165
+ .join('\n')
166
+ return { css, dropped, declared }
129
167
  }
130
168
 
131
169
  /**
@@ -141,7 +179,7 @@ export function resolveFrameTokens(
141
179
  /*
142
180
  * Faces embedded, since the type tokens name `Geist Variable` and a
143
181
  * machine with only static Geist installed renders every frame in the
144
- * fallback. An installed base owns its fonts, so that branch gets none.
182
+ * fallback.
145
183
  */
146
184
  return {
147
185
  source: 'toolkit',
@@ -151,10 +189,14 @@ export function resolveFrameTokens(
151
189
 
152
190
  const files = installedFiles(root)
153
191
  if (files.length > 0) {
154
- const css = files
155
- .map((file) => `/* ${file} */\n${readFileSync(join(root, file), 'utf8')}`)
156
- .join('\n')
157
- return { source: 'installed', css, files }
192
+ const { css, dropped, declared } = installedSheets(root, files)
193
+ const faces = toolkitFaces(css, declared)
194
+ return {
195
+ source: 'installed',
196
+ css: faces.length > 0 ? `${fontFaceBlock(faces)}\n${css}` : css,
197
+ files,
198
+ ...(dropped.length > 0 && { dropped }),
199
+ }
158
200
  }
159
201
 
160
202
  return { source: 'none', css: '', notice: NONE_NOTICE }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { extname, join } from 'node:path'
3
3
  import { isBinary } from '@/sweep/binary'
4
- import { listRepositoryFiles } from '@/git-files'
4
+ import { listRepositoryFiles } from '@/git/files'
5
5
 
6
6
  export type CensusRefusal = 'no-git'
7
7
 
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { copyPreservingMode } from '@/copy'
4
+ import { copyPreservingMode } from '@/sync/copy'
5
5
  import { creationRel, isRecordEntry } from '@/record-root'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { isFamilyKey } from '@/claude/skills-families'
3
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
4
5
  import { bodyLines, maskDisplayed } from '@/markdown/scan'
5
6
 
@@ -128,6 +129,7 @@ export interface SkillsAudit {
128
129
  readonly datedProvenance: readonly SkillFinding[]
129
130
  readonly practiceShape: readonly SkillFinding[]
130
131
  readonly referenceContents: readonly SkillFinding[]
132
+ readonly family: readonly SkillFinding[]
131
133
  }
132
134
 
133
135
  interface SkillSource {
@@ -136,6 +138,8 @@ interface SkillSource {
136
138
  readonly hasReadme: boolean
137
139
  readonly name: string | undefined
138
140
  readonly description: string | undefined
141
+ /** `metadata.family`, undefined when absent, blank, or not a string. */
142
+ readonly family: string | undefined
139
143
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
140
144
  readonly requirementHeadings: readonly string[] | undefined
141
145
  /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
@@ -207,6 +211,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
207
211
  referenceContents: sources.flatMap((source) =>
208
212
  source.bodies.flatMap(contentsFindings),
209
213
  ),
214
+ family: sources.flatMap(familyFindings),
210
215
  }
211
216
  }
212
217
 
@@ -261,6 +266,7 @@ async function readSkill(
261
266
  hasReadme: existsSync(join(skillDir, 'README.md')),
262
267
  name: declared(readField(fields, 'name')),
263
268
  description: declared(readField(fields, 'description')),
269
+ family: declaredFamily(fields?.fields.metadata),
264
270
  requirementHeadings:
265
271
  requirement === undefined ? undefined : headings(requirement),
266
272
  sections: sectionsOf(body),
@@ -285,6 +291,13 @@ function declared(value: string | undefined): string | undefined {
285
291
  return trimmed === undefined || trimmed === '' ? undefined : trimmed
286
292
  }
287
293
 
294
+ function declaredFamily(metadata: unknown): string | undefined {
295
+ if (typeof metadata !== 'object' || metadata === null) return undefined
296
+ if (Array.isArray(metadata)) return undefined
297
+ const { family } = metadata as Record<string, unknown>
298
+ return typeof family === 'string' ? declared(family) : undefined
299
+ }
300
+
288
301
  /** Every H2 outside a fence, the one level both exact-section checks read. */
289
302
  function sectionsOf(source: string): string[] {
290
303
  return bodyLines(source)
@@ -375,6 +388,21 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
375
388
  return [...sections, ...ledger]
376
389
  }
377
390
 
391
+ /**
392
+ * Asks only the shipped corpus for a family, since the family names the skill
393
+ * map group a skill's row sits under and an internal skill takes no row.
394
+ */
395
+ function familyFindings(source: SkillSource): SkillFinding[] {
396
+ if (dirname(source.rel) !== SHIPPED_CORPUS) return []
397
+ if (source.family === undefined) {
398
+ return [
399
+ { rel: source.rel, detail: 'frontmatter declares no metadata.family' },
400
+ ]
401
+ }
402
+ if (isFamilyKey(source.family)) return []
403
+ return [{ rel: source.rel, detail: `unknown family: ${source.family}` }]
404
+ }
405
+
378
406
  /**
379
407
  * Reads references alone, since `SKILL.md` carries its own length checkpoint
380
408
  * and no contents rule. Frontmatter counts toward the length, so the count a
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { execaSync } from 'execa'
4
- import { gitEnv } from '@/git-env'
4
+ import { gitEnv } from '@/git/env'
5
5
 
6
6
  /**
7
7
  * The shipped corpus alone. A session loads a body from the plugin root, which
@@ -0,0 +1,36 @@
1
+ export interface SkillFamily {
2
+ /** The value a shipped `SKILL.md` declares as `metadata.family`. */
3
+ readonly key: string
4
+ /** The skill map heading the family's rows sit under, verbatim. */
5
+ readonly group: string
6
+ }
7
+
8
+ /**
9
+ * The one vocabulary a shipped skill's family is drawn from, in the order the
10
+ * skill map reads across its two pages. A heading is the map's prose and a key
11
+ * is what frontmatter carries, so renaming a heading touches this constant and
12
+ * no skill.
13
+ *
14
+ * Import-free on purpose: `skills-list.ts` and `skills-audit.ts` both read it,
15
+ * and the first already imports the second.
16
+ */
17
+ export const SKILL_FAMILIES: readonly SkillFamily[] = [
18
+ { key: 'setup', group: 'Set up a project' },
19
+ { key: 'decide', group: 'Decide what to build' },
20
+ { key: 'build', group: 'Build the feature' },
21
+ { key: 'check', group: 'Check the work before it leaves the branch' },
22
+ { key: 'ship', group: 'Ship it' },
23
+ { key: 'after-pr', group: 'After the pull request opens' },
24
+ { key: 'parallel', group: 'Run several tracks at once' },
25
+ { key: 'upkeep', group: 'Keep the project current with the toolkit' },
26
+ { key: 'generate', group: 'Generate an artifact on demand' },
27
+ { key: 'answer', group: 'Answer a question at any point' },
28
+ ]
29
+
30
+ export function familyKeys(): string[] {
31
+ return SKILL_FAMILIES.map((family) => family.key)
32
+ }
33
+
34
+ export function isFamilyKey(value: string): boolean {
35
+ return SKILL_FAMILIES.some((family) => family.key === value)
36
+ }