@erclx/canon 5.24.0 → 5.26.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 (185) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  4. package/claude/skills/backlog-triage/SKILL.md +19 -6
  5. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  6. package/claude/skills/canvas/SKILL.md +106 -0
  7. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  8. package/claude/skills/context-fold/SKILL.md +8 -6
  9. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  10. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/SKILL.md +4 -2
  13. package/claude/skills/design-taste/references/adopted.md +40 -0
  14. package/claude/skills/design-taste/references/kinds.md +1 -1
  15. package/claude/skills/design-taste/references/systems.md +1 -7
  16. package/claude/skills/design-taste/references/tells.md +2 -24
  17. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  18. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  19. package/claude/skills/document-health/SKILL.md +21 -0
  20. package/claude/skills/document-health/references/axes.md +12 -0
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  24. package/claude/skills/draft-figure/SKILL.md +24 -51
  25. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  26. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  27. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  28. package/claude/skills/draft-identity/SKILL.md +1 -1
  29. package/claude/skills/draft-screencast/SKILL.md +9 -7
  30. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  31. package/claude/skills/draft-slides/SKILL.md +31 -20
  32. package/claude/skills/git-followup/SKILL.md +17 -6
  33. package/claude/skills/git-issue/references/issue.md +10 -0
  34. package/claude/skills/git-pr/references/evidence.md +12 -2
  35. package/claude/skills/git-pr/references/labels.md +12 -0
  36. package/claude/skills/git-split/SKILL.md +19 -8
  37. package/claude/skills/index-lookup/SKILL.md +2 -2
  38. package/claude/skills/markdown-propose/references/format.md +9 -0
  39. package/claude/skills/memory-review/references/later-phases.md +7 -0
  40. package/claude/skills/review-address/SKILL.md +6 -13
  41. package/claude/skills/review-address/references/rest-reads.md +43 -0
  42. package/claude/skills/review-craft/references/ui.md +7 -1
  43. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  44. package/claude/skills/review-pr/SKILL.md +9 -3
  45. package/claude/skills/review-pr/references/close-out.md +2 -2
  46. package/claude/skills/review-pr/references/post.md +17 -5
  47. package/claude/skills/review-pr/references/scope.md +10 -8
  48. package/claude/skills/review-ui/SKILL.md +12 -4
  49. package/claude/skills/review-ui/references/post.md +4 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  54. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  55. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  56. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  57. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  58. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  59. package/claude/skills/search-craft/SKILL.md +3 -1
  60. package/claude/skills/search-craft/references/adopted.md +30 -0
  61. package/claude/skills/target-setup/references/detect.md +8 -0
  62. package/claude/skills/target-setup/references/indexes.md +13 -0
  63. package/claude/skills/target-setup/references/verify.md +8 -0
  64. package/claude/skills/test-first/SKILL.md +2 -0
  65. package/claude/skills/test-first/references/adopted.md +36 -0
  66. package/claude/skills/write-human/SKILL.md +1 -1
  67. package/claude/skills/write-human/references/machine-tells.md +13 -0
  68. package/docs/agents/canvas.md +4 -2
  69. package/docs/agents/capture.md +1 -1
  70. package/docs/agents/commands.md +47 -47
  71. package/docs/agents/context-audit-checks.md +2 -2
  72. package/docs/agents/index.md +2 -3
  73. package/docs/agents/key-changes.md +3 -3
  74. package/docs/agents/pr-evidence.md +11 -2
  75. package/docs/agents/pr-reads.md +5 -4
  76. package/docs/agents/records-stale.md +22 -9
  77. package/docs/agents/records.md +1 -1
  78. package/docs/agents/sandbox.md +1 -1
  79. package/docs/agents/skills-audit.md +10 -3
  80. package/docs/agents/slides.md +15 -3
  81. package/docs/target/projects.md +4 -4
  82. package/docs/workflow/ai-workflow.md +1 -2
  83. package/docs/workflow/index.md +1 -0
  84. package/docs/workflow/parallel-features.md +1 -1
  85. package/docs/workflow/skill-map-on-request.md +42 -0
  86. package/docs/workflow/skill-map.md +3 -36
  87. package/docs/workflow/visual-design-workflow.md +1 -0
  88. package/governance/renames.toml +0 -4
  89. package/governance/rules/standards/markdown.md +1 -0
  90. package/package.json +1 -2
  91. package/scripts/core/install-check.sh +1 -1
  92. package/scripts/lib/sandbox-dispatch.sh +1 -1
  93. package/scripts/lib/sandbox-git.sh +1 -9
  94. package/scripts/lib/sandbox-path.sh +1 -1
  95. package/scripts/sandbox-hook.sh +99 -0
  96. package/src/audits/catalog.ts +2 -2
  97. package/src/canvas/capture.ts +1 -9
  98. package/src/canvas/client/inspector/color-field.tsx +368 -0
  99. package/src/canvas/client/inspector/field.tsx +200 -0
  100. package/src/canvas/client/inspector/fields.css +292 -0
  101. package/src/canvas/client/inspector/section.tsx +27 -0
  102. package/src/canvas/client/inspector/values.ts +183 -0
  103. package/src/canvas/client/inspector.tsx +176 -160
  104. package/src/canvas/client/shell.css +0 -101
  105. package/src/capture/render.ts +45 -7
  106. package/src/capture/sources.ts +43 -0
  107. package/src/census/count.ts +1 -1
  108. package/src/claude/cases/misc.ts +5 -0
  109. package/src/claude/cases/workflow.ts +6 -1
  110. package/src/claude/seeds.ts +1 -2
  111. package/src/claude/skills-audit.ts +68 -14
  112. package/src/client-commands.ts +1 -1
  113. package/src/commands/claude.ts +27 -2
  114. package/src/commands/design.ts +2 -64
  115. package/src/commands/feedback.ts +3 -3
  116. package/src/commands/pr.ts +219 -202
  117. package/src/commands/records.ts +88 -6
  118. package/src/commands/sandbox.ts +50 -10
  119. package/src/commands/slides.ts +31 -77
  120. package/src/context/audit.ts +8 -7
  121. package/src/design/tokens.ts +2 -18
  122. package/src/docs/read.ts +1 -1
  123. package/src/{github.ts → feedback/github.ts} +1 -1
  124. package/src/gate/measures.ts +6 -4
  125. package/src/gov/citations.ts +1 -1
  126. package/src/gov/payload.ts +1 -1
  127. package/src/gov/superseded.ts +2 -2
  128. package/src/help.ts +2 -2
  129. package/src/markdown/ceiling.ts +1 -1
  130. package/src/migrate/skill-names.ts +7 -1
  131. package/src/pr/evidence.ts +25 -5
  132. package/src/pr/rest.ts +137 -0
  133. package/src/record-root.ts +1 -0
  134. package/src/records/backup.ts +1 -0
  135. package/src/records/push-guard.ts +1 -1
  136. package/src/records/stale-canonical.ts +146 -0
  137. package/src/records/stale.ts +1 -1
  138. package/src/sandbox/hooks.ts +128 -0
  139. package/src/sandbox/provision.ts +596 -0
  140. package/src/sandbox/tree.ts +103 -4
  141. package/src/secrets/marker.ts +2 -2
  142. package/src/secrets/scan.ts +1 -1
  143. package/src/shipped/references.ts +2 -2
  144. package/src/slides/convert/export.ts +2 -16
  145. package/src/slides/decks.ts +110 -0
  146. package/src/standards/read.ts +1 -1
  147. package/src/sync/seeds-report.ts +1 -1
  148. package/src/tasks/answers.ts +1 -1
  149. package/src/tasks/archive.ts +1 -1
  150. package/src/teach/nav.ts +1 -1
  151. package/src/tooling/inject.ts +1 -1
  152. package/src/web/readme-citations.ts +1 -1
  153. package/standards/architecture.md +1 -0
  154. package/standards/canvas.md +112 -0
  155. package/standards/context.md +2 -1
  156. package/standards/figures.md +0 -1
  157. package/standards/index.md +1 -1
  158. package/standards/markdown.md +1 -0
  159. package/standards/mermaid.md +1 -7
  160. package/standards/requirements.md +2 -0
  161. package/standards/skill-practice.md +2 -1
  162. package/standards/skill.md +1 -0
  163. package/tooling/claude/reference.md +0 -5
  164. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  165. package/claude/skills/draft-diagram/SKILL.md +0 -177
  166. package/docs/agents/design-board.md +0 -33
  167. package/governance/rules/standards/diagrams.md +0 -14
  168. package/governance/rules/standards/mermaid.md +0 -15
  169. package/scripts/core/check-gallery-exclusion.sh +0 -50
  170. package/scripts/manage-sandbox.sh +0 -472
  171. package/src/design/board.ts +0 -576
  172. package/src/slides/layouts.ts +0 -478
  173. package/src/slides/parse.ts +0 -84
  174. package/src/slides/render.ts +0 -110
  175. package/src/slides/styles.ts +0 -96
  176. package/standards/diagrams.md +0 -132
  177. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
  178. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  179. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  180. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  181. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  182. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  183. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  184. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  185. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -1,6 +1,19 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
4
+ import { ColorField } from '@/canvas/client/inspector/color-field'
5
+ import {
6
+ Field,
7
+ ReadOnlyField,
8
+ type Scrub,
9
+ } from '@/canvas/client/inspector/field'
10
+ import { Section } from '@/canvas/client/inspector/section'
11
+ import {
12
+ clampScrub,
13
+ displayValue,
14
+ parseColor,
15
+ toCssValue,
16
+ } from '@/canvas/client/inspector/values'
4
17
  import {
5
18
  currentPage,
6
19
  editElement,
@@ -16,8 +29,7 @@ import {
16
29
  tokens,
17
30
  writeError,
18
31
  } from '@/canvas/client/state'
19
-
20
- const FIELDS = ['x', 'y', 'width', 'height'] as const
32
+ import '@/canvas/client/inspector/fields.css'
21
33
 
22
34
  /** Elements the parser never gives content, so they hold no text to edit. */
23
35
  const VOID = new Set([
@@ -37,44 +49,38 @@ const VOID = new Set([
37
49
  ])
38
50
 
39
51
  interface StyleField {
52
+ /** The full word, which names the field. */
40
53
  readonly label: string
54
+ readonly glyph: string
41
55
  readonly property: string
42
- readonly isColor?: boolean
56
+ readonly isWide?: boolean
57
+ readonly placeholder?: string
43
58
  }
44
59
 
45
60
  /**
46
- * The basic set, in the order the panel lists it. The writer refuses any
47
- * property outside it, so this list and the writer's have to agree.
61
+ * The basic set, grouped as the panel lists it. The writer refuses any
62
+ * property outside it, so these lists and the writer's have to agree.
48
63
  */
49
- const STYLE_FIELDS: readonly StyleField[] = [
50
- { label: 'color', property: 'color', isColor: true },
51
- { label: 'background', property: 'background-color', isColor: true },
52
- { label: 'size', property: 'font-size' },
53
- { label: 'weight', property: 'font-weight' },
54
- { label: 'width', property: 'width' },
55
- { label: 'height', property: 'height' },
56
- { label: 'padding', property: 'padding' },
57
- { label: 'gap', property: 'gap' },
58
- { label: 'direction', property: 'flex-direction' },
64
+ const SIZE_FIELDS: readonly StyleField[] = [
65
+ { label: 'width', glyph: 'W', property: 'width' },
66
+ { label: 'height', glyph: 'H', property: 'height' },
59
67
  ]
60
68
 
61
- interface Row {
62
- readonly name: string
63
- readonly value: string
64
- }
69
+ const FLEX_FIELDS: readonly StyleField[] = [
70
+ { label: 'direction', glyph: 'Dir', property: 'flex-direction' },
71
+ { label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
72
+ { label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
73
+ ]
65
74
 
66
- /** Where the element sits in its frame, which the panel shows and never edits. */
67
- function positionRows(element: Element): Row[] {
68
- const rect = element.getBoundingClientRect()
69
- const font = element.ownerDocument.defaultView
70
- ?.getComputedStyle(element)
71
- .getPropertyValue('font-family')
72
- return [
73
- { name: 'x', value: String(Math.round(rect.left)) },
74
- { name: 'y', value: String(Math.round(rect.top)) },
75
- ...(font ? [{ name: 'font', value: font }] : []),
76
- ]
77
- }
75
+ const TYPE_FIELDS: readonly StyleField[] = [
76
+ { label: 'size', glyph: 'Size', property: 'font-size' },
77
+ { label: 'weight', glyph: 'Wt', property: 'font-weight' },
78
+ ]
79
+
80
+ const FILL_FIELDS = [
81
+ { label: 'color', property: 'color' },
82
+ { label: 'background', property: 'background-color' },
83
+ ] as const
78
84
 
79
85
  function inlineValue(element: Element, property: string): string {
80
86
  return 'style' in element
@@ -118,8 +124,10 @@ function isRawInline(element: Element, property: string): boolean {
118
124
  * an edit starts from what the operator sees.
119
125
  */
120
126
  function currentValue(element: Element, property: string): string {
121
- const inline = inlineValue(element, property)
122
- if (inline) return inline
127
+ return inlineValue(element, property) || computedValue(element, property)
128
+ }
129
+
130
+ function computedValue(element: Element, property: string): string {
123
131
  return (
124
132
  element.ownerDocument.defaultView
125
133
  ?.getComputedStyle(element)
@@ -127,6 +135,19 @@ function currentValue(element: Element, property: string): string {
127
135
  )
128
136
  }
129
137
 
138
+ /**
139
+ * The color a token paints in the frame, read off a probe rather than the
140
+ * token's text, since a token's value may itself be a `var()` or a mix.
141
+ */
142
+ function tokenPaint(doc: Document, name: string): string {
143
+ const probe = doc.createElement('span')
144
+ probe.style.setProperty('color', `var(${name})`)
145
+ ;(doc.body ?? doc.documentElement).append(probe)
146
+ const painted = computedValue(probe, 'color')
147
+ probe.remove()
148
+ return painted
149
+ }
150
+
130
151
  function holdsTextAlone(element: Element): boolean {
131
152
  const tag = element.tagName.toLowerCase()
132
153
  return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
@@ -137,71 +158,6 @@ function elementName(element: Element): string {
137
158
  return [tag, ...element.classList].join('.')
138
159
  }
139
160
 
140
- interface FieldProps {
141
- readonly label: string
142
- readonly initial: string
143
- readonly isBusy: boolean
144
- readonly onCommit: (value: string) => void
145
- }
146
-
147
- /**
148
- * Commits on change, which a text input fires on Enter or on leaving it, and
149
- * sends nothing when the value is what it started at. Escape puts it back.
150
- */
151
- function Field({ label, initial, isBusy, onCommit }: FieldProps): JSX.Element {
152
- return (
153
- <input
154
- class="field"
155
- type="text"
156
- aria-label={label}
157
- title={initial}
158
- defaultValue={initial}
159
- disabled={isBusy}
160
- spellcheck={false}
161
- onChange={(event) => {
162
- const value = event.currentTarget.value.trim()
163
- if (value !== initial.trim()) onCommit(value)
164
- }}
165
- onKeyDown={(event) => {
166
- if (event.key === 'Escape') event.currentTarget.value = initial
167
- }}
168
- />
169
- )
170
- }
171
-
172
- function TokenPicker({
173
- label,
174
- initial,
175
- isBusy,
176
- onCommit,
177
- }: FieldProps): JSX.Element | null {
178
- const colors =
179
- tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
180
- if (colors.length === 0) return null
181
- return (
182
- <select
183
- class="token-picker"
184
- aria-label={`${label} token`}
185
- title="Pick a project token"
186
- value={
187
- colors.some((token) => initial === `var(${token.name})`) ? initial : ''
188
- }
189
- disabled={isBusy}
190
- onChange={(event) => {
191
- const { value } = event.currentTarget
192
- if (value !== '' && value !== initial) onCommit(value)
193
- }}
194
- >
195
- <option value="">Token</option>
196
- {colors.map((token) => (
197
- <option key={token.name} value={`var(${token.name})`}>
198
- {token.name}
199
- </option>
200
- ))}
201
- </select>
202
- )
203
- }
204
-
205
161
  function ElementFields({
206
162
  node,
207
163
  doc,
@@ -219,65 +175,119 @@ function ElementFields({
219
175
  const commit = (property: string) => (value: string) => {
220
176
  if (address) void editElement(frameRef, key, address, property, value)
221
177
  }
222
- const isTextOnly = holdsTextAlone(node)
178
+ const rect = node.getBoundingClientRect()
179
+ const font = currentValue(node, 'font-family')
223
180
  const text = node.textContent ?? ''
181
+ const colorTokens =
182
+ tokens.value?.groups
183
+ ?.find((group) => group.kind === 'color')
184
+ ?.tokens.map((token) => token.name) ?? []
185
+
186
+ /**
187
+ * Previews into the frame's own inline style and writes through the same
188
+ * edit as typing. A reload mid-drag replaces the document, so a release
189
+ * against one no longer on screen posts nothing.
190
+ */
191
+ const scrubOf = (property: string): Scrub => ({
192
+ clamp: (value) => clampScrub(property, value),
193
+ begin: () => {
194
+ const original = inlineValue(node, property)
195
+ const style = (node as HTMLElement).style
196
+ return {
197
+ preview: (shown) =>
198
+ style.setProperty(property, toCssValue(property, shown)),
199
+ restore: () =>
200
+ original
201
+ ? style.setProperty(property, original)
202
+ : style.removeProperty(property),
203
+ commit: (shown) => {
204
+ if (frameDocuments.value.get(key) !== doc || !node.isConnected) return
205
+ commit(property)(toCssValue(property, shown))
206
+ },
207
+ }
208
+ },
209
+ })
210
+
211
+ const styleField = (field: StyleField): JSX.Element => (
212
+ <Field
213
+ key={field.property}
214
+ label={field.label}
215
+ glyph={field.glyph}
216
+ initial={displayValue(field.property, currentValue(node, field.property))}
217
+ isBusy={isBusy}
218
+ isWide={field.isWide}
219
+ placeholder={field.placeholder}
220
+ onCommit={(typed) =>
221
+ commit(field.property)(toCssValue(field.property, typed))
222
+ }
223
+ scrub={'style' in node ? scrubOf(field.property) : undefined}
224
+ />
225
+ )
224
226
 
225
227
  return (
226
- <dl class="box styles">
227
- {positionRows(node).map((row) => (
228
- <div key={row.name} class="box-row">
229
- <dt>{row.name}</dt>
230
- <dd title={row.value}>{row.value}</dd>
231
- </div>
232
- ))}
233
- <div class="box-row">
234
- <dt>text</dt>
235
- <dd title={excerpt(text)}>
236
- {isTextOnly ? (
237
- <Field
238
- label="text"
239
- initial={text}
228
+ <>
229
+ <Section title="Layout">
230
+ <ReadOnlyField
231
+ label="x"
232
+ glyph="X"
233
+ value={String(Math.round(rect.left))}
234
+ />
235
+ <ReadOnlyField
236
+ label="y"
237
+ glyph="Y"
238
+ value={String(Math.round(rect.top))}
239
+ />
240
+ {SIZE_FIELDS.map(styleField)}
241
+ </Section>
242
+ <Section title="Flex">{FLEX_FIELDS.map(styleField)}</Section>
243
+ <Section title="Typography">
244
+ {TYPE_FIELDS.map(styleField)}
245
+ {font ? (
246
+ <ReadOnlyField label="font" glyph="Font" value={font} isWide />
247
+ ) : null}
248
+ </Section>
249
+ <Section title="Fill">
250
+ {FILL_FIELDS.map((field) => {
251
+ const painted = computedValue(node, field.property)
252
+ const inline = inlineValue(node, field.property)
253
+ /*
254
+ * An inline value the picker cannot read stays as written. Reading
255
+ * the computed color in its place would show a token-following
256
+ * value as a literal, and an opacity edit would freeze it.
257
+ */
258
+ return (
259
+ <ColorField
260
+ key={field.property}
261
+ label={field.label}
262
+ color={parseColor(inline || painted)}
263
+ text={inline || painted}
264
+ painted={painted}
265
+ tokens={colorTokens}
266
+ paint={(name) => tokenPaint(doc, name)}
267
+ isRaw={isRawInline(node, field.property)}
240
268
  isBusy={isBusy}
241
- onCommit={commit('text')}
269
+ onCommit={commit(field.property)}
242
270
  />
243
- ) : (
244
- excerpt(text)
245
- )}
246
- </dd>
247
- </div>
248
- {STYLE_FIELDS.map((field) => {
249
- const initial = currentValue(node, field.property)
250
- return (
251
- <div key={field.property} class="box-row">
252
- <dt>{field.label}</dt>
253
- <dd class="field-cell">
254
- <Field
255
- label={field.label}
256
- initial={initial}
257
- isBusy={isBusy}
258
- onCommit={commit(field.property)}
259
- />
260
- {field.isColor && isRawInline(node, field.property) ? (
261
- <span
262
- class="raw"
263
- title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
264
- >
265
- raw
266
- </span>
267
- ) : null}
268
- {field.isColor ? (
269
- <TokenPicker
270
- label={field.label}
271
- initial={initial}
272
- isBusy={isBusy}
273
- onCommit={commit(field.property)}
274
- />
275
- ) : null}
276
- </dd>
277
- </div>
278
- )
279
- })}
280
- </dl>
271
+ )
272
+ })}
273
+ </Section>
274
+ <Section title="Text">
275
+ {holdsTextAlone(node) ? (
276
+ <Field
277
+ label="text"
278
+ glyph="T"
279
+ initial={text}
280
+ isBusy={isBusy}
281
+ isWide
282
+ onCommit={commit('text')}
283
+ />
284
+ ) : (
285
+ <p class="text-excerpt is-wide" title={excerpt(text)}>
286
+ {excerpt(text)}
287
+ </p>
288
+ )}
289
+ </Section>
290
+ </>
281
291
  )
282
292
  }
283
293
 
@@ -342,14 +352,20 @@ export function Inspector(): JSX.Element {
342
352
  <p class="detail" title={frame.name}>
343
353
  {frame.name}
344
354
  </p>
345
- <dl class="box">
346
- {FIELDS.map((field) => (
347
- <div key={field} class="box-row">
348
- <dt>{field}</dt>
349
- <dd>{frame[field]}</dd>
350
- </div>
351
- ))}
352
- </dl>
355
+ <div class="field-grid">
356
+ <ReadOnlyField label="x" glyph="X" value={String(frame.x)} />
357
+ <ReadOnlyField label="y" glyph="Y" value={String(frame.y)} />
358
+ <ReadOnlyField
359
+ label="width"
360
+ glyph="W"
361
+ value={String(frame.width)}
362
+ />
363
+ <ReadOnlyField
364
+ label="height"
365
+ glyph="H"
366
+ value={String(frame.height)}
367
+ />
368
+ </div>
353
369
  </>
354
370
  ) : (
355
371
  <p class="empty">Select a frame to see its box</p>
@@ -320,29 +320,6 @@ code {
320
320
  outline-offset: 6px;
321
321
  }
322
322
 
323
- .box {
324
- display: grid;
325
- grid-template-columns: 1fr 1fr;
326
- gap: var(--space-xs) var(--space-md);
327
- margin: var(--space-xs) 0 0;
328
- }
329
-
330
- .box-row {
331
- display: flex;
332
- justify-content: space-between;
333
- gap: var(--space-sm);
334
- }
335
-
336
- .box dt {
337
- color: var(--color-muted);
338
- }
339
-
340
- .box dd {
341
- margin: 0;
342
- color: var(--color-text);
343
- font-variant-numeric: tabular-nums;
344
- }
345
-
346
323
  .toolbar {
347
324
  position: absolute;
348
325
  right: var(--space-md);
@@ -436,84 +413,6 @@ code {
436
413
  white-space: nowrap;
437
414
  }
438
415
 
439
- /* One property a row, since a computed color or font stack outgrows a half. */
440
- .styles {
441
- grid-template-columns: 1fr;
442
- }
443
-
444
- .styles dd {
445
- min-width: 0;
446
- overflow: hidden;
447
- text-overflow: ellipsis;
448
- white-space: nowrap;
449
- }
450
-
451
- .styles .box-row {
452
- align-items: center;
453
- }
454
-
455
- .styles dt {
456
- flex: none;
457
- width: 4.5rem;
458
- }
459
-
460
- .styles dd {
461
- flex: 1;
462
- }
463
-
464
- .field-cell {
465
- display: flex;
466
- gap: var(--space-xs);
467
- }
468
-
469
- .field,
470
- .token-picker {
471
- min-width: 0;
472
- min-height: 24px;
473
- padding: 2px var(--space-xs);
474
- border: 1px solid var(--color-border);
475
- border-radius: var(--radius-action);
476
- background: var(--color-surface);
477
- color: var(--color-text);
478
- font: inherit;
479
- font-variant-numeric: tabular-nums;
480
- }
481
-
482
- .field {
483
- flex: 1;
484
- width: 100%;
485
- }
486
-
487
- .token-picker {
488
- flex: none;
489
- width: 4.5rem;
490
- cursor: pointer;
491
- }
492
-
493
- .raw {
494
- flex: none;
495
- align-self: center;
496
- color: var(--color-text-secondary);
497
- cursor: help;
498
- }
499
-
500
- .field:hover,
501
- .token-picker:hover {
502
- border-color: var(--color-muted);
503
- }
504
-
505
- .field:disabled,
506
- .token-picker:disabled {
507
- opacity: 0.5;
508
- cursor: not-allowed;
509
- }
510
-
511
- .saved {
512
- margin-left: var(--space-sm);
513
- color: var(--color-success);
514
- font-weight: 400;
515
- }
516
-
517
416
  .tabs {
518
417
  display: flex;
519
418
  gap: 2px;
@@ -2,9 +2,13 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { basename, dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { chromium } from 'playwright-core'
5
- import type { Browser, Page } from 'playwright-core'
5
+ import type { Browser, Locator, Page } from 'playwright-core'
6
6
  import type { CaptureSource } from '@/capture/sources'
7
- import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
7
+ import {
8
+ isGenericFamily,
9
+ resolveCaptureSources,
10
+ textFamilies,
11
+ } from '@/capture/sources'
8
12
  import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
9
13
 
10
14
  /**
@@ -97,15 +101,22 @@ async function captureOne(
97
101
  }
98
102
 
99
103
  await page.evaluate(() => document.fonts.ready.then(() => undefined))
100
- const family = primaryFontFamily(
101
- await element.evaluate((node) => getComputedStyle(node).fontFamily),
102
- )
103
- if (family && !(await resolvesFont(page, family))) {
104
+ const declarations = await renderedTextFamilies(element)
105
+ const generic = declarations.find(isGenericFamily)
106
+ if (generic !== undefined) {
104
107
  return failed(
105
108
  source,
106
- `${family} is not installed, so the capture would rewrap against a fallback`,
109
+ `${generic.split(',')[0]?.trim()} is a generic family that resolves differently per machine, so name a real font`,
107
110
  )
108
111
  }
112
+ for (const family of textFamilies(declarations)) {
113
+ if (!(await resolvesFont(page, family))) {
114
+ return failed(
115
+ source,
116
+ `${family} is not installed, so the capture would rewrap against a fallback`,
117
+ )
118
+ }
119
+ }
109
120
 
110
121
  mkdirSync(dirname(source.pngPath), { recursive: true })
111
122
  const png = await element.screenshot({ omitBackground: true })
@@ -170,6 +181,33 @@ function failed(source: CaptureSource, reason: string): CaptureResult {
170
181
  return { status: 'failed', source: sourceIdentifier(source), reason }
171
182
  }
172
183
 
184
+ /**
185
+ * The computed families of the captured element and of each element under it
186
+ * holding a non-blank text node of its own that draws a box, which is the text
187
+ * the screenshot shows. Testing the text node's box rather than the element's
188
+ * keeps out `<style>`, `<script>`, `<title>`, and anything hidden, whose
189
+ * computed family is the browser default and would refuse every source that
190
+ * styles a descendant rather than the root, and keeps in text directly inside a
191
+ * `display: contents` element, which draws no box of its own.
192
+ *
193
+ * Returns nothing when no element holds text, since a font cannot rewrap text
194
+ * that is not there, so a container of iframes skips the probe.
195
+ */
196
+ async function renderedTextFamilies(element: Locator): Promise<string[]> {
197
+ return element.evaluate((root) => {
198
+ const range = document.createRange()
199
+ const holdsText = (node: Element): boolean =>
200
+ [...node.childNodes].some((child) => {
201
+ if (child.nodeType !== Node.TEXT_NODE) return false
202
+ if ((child.textContent ?? '').trim() === '') return false
203
+ range.selectNodeContents(child)
204
+ return range.getClientRects().length > 0
205
+ })
206
+ const texts = [root, ...root.querySelectorAll('*')].filter(holdsText)
207
+ return texts.map((node) => getComputedStyle(node).fontFamily)
208
+ })
209
+ }
210
+
173
211
  /**
174
212
  * Compares the family's text metrics against a family that cannot exist. Equal
175
213
  * widths mean the browser fell through to its default for both, which is the
@@ -66,3 +66,46 @@ export function primaryFontFamily(declaration: string): string {
66
66
  const first = declaration.split(',')[0]?.trim() ?? ''
67
67
  return first.replace(/^['"]|['"]$/g, '')
68
68
  }
69
+
70
+ /**
71
+ * The CSS generic family keywords. A generic names no font, so each machine
72
+ * maps it to whatever it has, and a capture measured against one cannot
73
+ * reproduce on another.
74
+ */
75
+ export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
76
+ 'serif',
77
+ 'sans-serif',
78
+ 'monospace',
79
+ 'cursive',
80
+ 'fantasy',
81
+ 'system-ui',
82
+ 'ui-serif',
83
+ 'ui-sans-serif',
84
+ 'ui-monospace',
85
+ 'ui-rounded',
86
+ 'math',
87
+ 'emoji',
88
+ 'fangsong',
89
+ ])
90
+
91
+ /**
92
+ * Whether the first family of a computed `font-family` is a generic keyword.
93
+ * Takes the raw declaration rather than `primaryFontFamily`'s result, since
94
+ * quoting is what separates the keyword from a family that happens to share its
95
+ * name, and the stripped form has lost it. Keywords match case-insensitively.
96
+ */
97
+ export function isGenericFamily(declaration: string): boolean {
98
+ const first = declaration.split(',')[0]?.trim() ?? ''
99
+ return GENERIC_FAMILIES.has(first.toLowerCase())
100
+ }
101
+
102
+ /**
103
+ * Reduces the computed `font-family` of each rendered text element to the
104
+ * distinct families a capture has to prove resolved, in document order, so a
105
+ * frame that sets its font on any ancestor of its text is checked against the
106
+ * face the text actually draws in.
107
+ */
108
+ export function textFamilies(declarations: readonly string[]): string[] {
109
+ const families = declarations.map(primaryFontFamily).filter(Boolean)
110
+ return [...new Set(families)]
111
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { extname, join } from 'node:path'
3
- import { isBinary } from '@/binary'
3
+ import { isBinary } from '@/sweep/binary'
4
4
  import { listRepositoryFiles } from '@/git-files'
5
5
 
6
6
  export type CensusRefusal = 'no-git'
@@ -27,6 +27,11 @@ export const MISC_CASES: readonly SkillCase[] = [
27
27
  "I found two sites whose look I want us to draw from. Put them side by side and let's settle which one the design should follow before anyone writes a token.",
28
28
  expect: 'sketch-design',
29
29
  },
30
+ {
31
+ prompt:
32
+ 'Draw the signup screen as a frame on the canvas, then restyle the element I just selected there.',
33
+ expect: 'canvas',
34
+ },
30
35
  {
31
36
  prompt: 'Fire up the dev server the way this project documents it.',
32
37
  expect: 'project-commands',
@@ -26,7 +26,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
26
26
  },
27
27
  {
28
28
  prompt: 'Draw me a diagram of how the pieces of this system connect.',
29
- expect: 'draft-diagram',
29
+ expect: 'draft-figure',
30
30
  },
31
31
  {
32
32
  prompt:
@@ -146,6 +146,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
146
146
  'Which of our docs have gone stale, grown too long, or ended up in the wrong file?',
147
147
  expect: 'document-health',
148
148
  },
149
+ {
150
+ prompt:
151
+ 'Are our requirements and architecture docs still true, and when were they last reviewed?',
152
+ expect: 'document-health',
153
+ },
149
154
  {
150
155
  prompt: 'Open a new entry on the task board for this piece of work.',
151
156
  expect: 'task-board',