@erclx/canon 5.26.0 → 5.28.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 (320) 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 +5 -1
  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 +7 -7
  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/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -27,15 +27,46 @@ export const STYLE_PROPERTIES = [
27
27
  'background-color',
28
28
  'font-size',
29
29
  'font-weight',
30
+ 'font-family',
31
+ 'line-height',
32
+ 'letter-spacing',
33
+ 'text-align',
30
34
  'width',
31
35
  'height',
32
36
  'padding',
33
37
  'gap',
38
+ 'display',
34
39
  'flex-direction',
40
+ 'justify-content',
41
+ 'align-items',
42
+ 'flex-wrap',
43
+ 'opacity',
44
+ 'border-radius',
45
+ 'border-top-left-radius',
46
+ 'border-top-right-radius',
47
+ 'border-bottom-right-radius',
48
+ 'border-bottom-left-radius',
35
49
  ] as const
36
50
 
37
51
  export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
38
52
 
53
+ /**
54
+ * The longhands a shorthand write drops, so a later longhand in the attribute
55
+ * does not keep overriding the value just set. A longhand write leaves its
56
+ * shorthand alone, since the longhand comes later and already wins.
57
+ */
58
+ const SHORTHAND_LONGHANDS: Readonly<
59
+ Partial<Record<StyleProperty, readonly string[]>>
60
+ > = {
61
+ 'border-radius': [
62
+ 'border-top-left-radius',
63
+ 'border-top-right-radius',
64
+ 'border-bottom-right-radius',
65
+ 'border-bottom-left-radius',
66
+ ],
67
+ padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
68
+ }
69
+
39
70
  export type EditProperty = StyleProperty | 'text'
40
71
 
41
72
  export interface EditChange {
@@ -106,10 +137,13 @@ function checkChange(change: EditChange): EditRefused | undefined {
106
137
  return undefined
107
138
  }
108
139
 
140
+ const CHARACTER_REFERENCE = /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/iy
141
+
109
142
  /**
110
143
  * Splits a style attribute at each semicolon outside parentheses and quotes,
111
144
  * keeping every segment's own spacing so an untouched one is written back as
112
- * it was.
145
+ * it was. The rewriter hands the attribute over undecoded and writes a quote
146
+ * back as `&quot;`, so a character reference's own semicolon never splits.
113
147
  */
114
148
  function declarations(style: string): string[] {
115
149
  const segments: string[] = []
@@ -118,7 +152,10 @@ function declarations(style: string): string[] {
118
152
  let start = 0
119
153
  for (let at = 0; at < style.length; at += 1) {
120
154
  const char = style[at]
121
- if (quote) {
155
+ CHARACTER_REFERENCE.lastIndex = at
156
+ if (char === '&' && CHARACTER_REFERENCE.test(style)) {
157
+ at = CHARACTER_REFERENCE.lastIndex - 1
158
+ } else if (quote) {
122
159
  if (char === quote) quote = undefined
123
160
  } else if (char === '"' || char === "'") quote = char
124
161
  else if (char === '(') depth += 1
@@ -139,15 +176,18 @@ function propertyOf(segment: string): string {
139
176
  /**
140
177
  * Sets one property in a style attribute, or drops it on an empty value. The
141
178
  * first declaration of it is replaced in place and any later one dropped, so
142
- * nothing further in the attribute overrides the edit. Undefined means no
143
- * style is left.
179
+ * nothing further in the attribute overrides the edit. A shorthand also drops
180
+ * its longhands. Undefined means no style is left.
144
181
  */
145
182
  export function setDeclaration(
146
183
  style: string,
147
184
  property: StyleProperty,
148
185
  value: string,
149
186
  ): string | undefined {
150
- const segments = declarations(style)
187
+ const longhands = SHORTHAND_LONGHANDS[property] ?? []
188
+ const segments = declarations(style).filter(
189
+ (segment) => !longhands.includes(propertyOf(segment)),
190
+ )
151
191
  const matches = segments.filter((segment) => propertyOf(segment) === property)
152
192
  const declaration = `${property}: ${value.trim()}`
153
193
 
@@ -164,7 +204,10 @@ export function setDeclaration(
164
204
  return [`${lead}${declaration}`]
165
205
  })
166
206
  } else {
167
- const kept = style.replace(/[\s;]*$/, '')
207
+ const kept = segments
208
+ .join(';')
209
+ .replace(/^[\s;]+/, '')
210
+ .replace(/[\s;]*$/, '')
168
211
  return kept === '' ? declaration : `${kept}; ${declaration}`
169
212
  }
170
213
 
@@ -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,8 +1,19 @@
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'
5
- import { isOwnCheckout } from '@/project-root'
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'
16
+ import { isOwnCheckout } from '@/roots/project'
6
17
 
7
18
  /**
8
19
  * The stylesheet injected into every frame, so a frame drawn with
@@ -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,10 +1,10 @@
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'
5
- import { creationRel, isRecordEntry } from '@/record-root'
4
+ import { copyPreservingMode } from '@/sync/copy'
5
+ import { creationRel, isRecordEntry } from '@/roots/record'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
- import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
7
+ import { SURFACE_ENTRIES, surfaceDir } from '@/roots/surface'
8
8
 
9
9
  const SEEDS_DIR = join('tooling', 'claude', 'seeds')
10
10
  const CLAUDE_DIR = '.claude'