@erclx/canon 5.16.0 → 5.18.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 (57) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  3. package/claude/skills/deploy-app/SKILL.md +45 -0
  4. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  5. package/claude/skills/deploy-app/references/vercel.md +72 -0
  6. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  7. package/claude/skills/draft-figure/SKILL.md +19 -4
  8. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/architecture-record.md +12 -4
  12. package/docs/agents/canvas.md +13 -1
  13. package/docs/agents/commands.md +2 -2
  14. package/docs/agents/context-audit-checks.md +1 -1
  15. package/docs/agents/context-audit.md +4 -4
  16. package/docs/agents/demo.md +3 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/teach.md +2 -2
  19. package/docs/workflow/skill-map.md +9 -9
  20. package/docs/workflow/visual-design-workflow.md +8 -15
  21. package/governance/rules/claude/566-output.md +3 -5
  22. package/governance/rules/claude/575-hooks.md +1 -1
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +46 -1
  25. package/src/canvas/address.ts +73 -39
  26. package/src/canvas/client/inspector.tsx +241 -33
  27. package/src/canvas/client/pages-panel.tsx +51 -0
  28. package/src/canvas/client/shell.css +126 -1
  29. package/src/canvas/client/state.ts +120 -4
  30. package/src/canvas/client/theme-panel.tsx +55 -0
  31. package/src/canvas/content.ts +14 -3
  32. package/src/canvas/edit.ts +360 -0
  33. package/src/canvas/server.ts +50 -5
  34. package/src/canvas/tokens.ts +82 -0
  35. package/src/claude/cases/misc.ts +5 -1
  36. package/src/commands/canvas.ts +95 -1
  37. package/src/commands/context.ts +85 -5
  38. package/src/commands/demo.ts +2 -0
  39. package/src/commands/teach.ts +3 -1
  40. package/src/context/architecture.ts +98 -18
  41. package/src/context/gate.ts +30 -3
  42. package/src/context/requirements.ts +77 -0
  43. package/src/demo/drive.ts +36 -7
  44. package/src/demo/timeline.ts +64 -0
  45. package/src/gate/measures.ts +69 -28
  46. package/src/gate/stages.ts +5 -5
  47. package/src/teach/workspace.ts +61 -6
  48. package/standards/architecture.md +13 -7
  49. package/standards/figures.md +1 -0
  50. package/standards/requirements.md +4 -0
  51. package/tooling/claude/reference.md +1 -1
  52. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  53. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  54. package/tooling/claude/seeds/.claude/settings.json +9 -0
  55. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  56. package/tooling/cloudflare/reference.md +1 -1
  57. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -8,9 +8,9 @@ category: Workflow
8
8
 
9
9
  Three tiers cover the range from prose-only design docs to a fully graphical design source of truth. Pick one per project based on how UI-heavy the work is, whether stakeholders review visuals, and whether a designer is involved. Tiers stack, so moving up does not invalidate work done at a lower tier.
10
10
 
11
- The tier framework sits alongside [Claude Design](../../wiki/claude/design.md), the one catalog page in this range. Stitch, Excalidraw, and the surrounding MCP and skill ecosystem are named inline below rather than catalogued separately. This page decides when to reach for what.
11
+ The tier framework sits alongside [Claude Design](../../wiki/claude/design.md), the one catalog page in this range. Excalidraw and the surrounding MCP and skill ecosystem are named inline below rather than catalogued separately. This page decides when to reach for what.
12
12
 
13
- Two tools anchor tier 1 and tier 2. Stitch is the agent-addressable default through its MCP server at `stitch.googleapis.com/mcp`, with a free tier of 400 daily credits that covers daily iteration. [Claude Design](../../wiki/claude/design.md), released 2026-04-17 and priced inside Claude subscriptions, is the ceiling tool reserved for codebase extraction and the richly annotated handoff bundle. Each covers a different job, they are not swappable.
13
+ [Claude Design](../../wiki/claude/design.md), released 2026-04-17 and priced inside Claude subscriptions, is the ceiling tool for tier 1 and tier 2. Reserve it for codebase extraction and the richly annotated handoff bundle.
14
14
 
15
15
  ## Tier 0: prose only
16
16
 
@@ -56,21 +56,19 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
56
56
 
57
57
  ASCII and prose stay as source of truth. Add a visual render as a feedback surface for the agent and for human review.
58
58
 
59
- - Stitch via MCP handles agent-driven generation of prototypes and design systems
60
59
  - Excalidraw handles agent-driven wireframes when the round-trip canvas loop matters
61
60
  - Claude Design joins only when codebase extraction or the annotated handoff bundle is worth the ceiling cost
62
61
 
63
- All three produce derived artifacts, so human edits are review annotations rather than source changes.
62
+ Both produce derived artifacts, so human edits are review annotations rather than source changes.
64
63
 
65
64
  ### Seed shape
66
65
 
67
- Same as tier 0 with two additions. `WIREFRAMES.md` opts into Excalidraw rendering via a top-of-file marker like `<!-- excalidraw: WIREFRAMES.excalidraw -->`. `DESIGN.md` stays human-maintained, and its content is what the toolkit provisions into Stitch via `create_design_system` when visual generation is needed.
66
+ Same as tier 0 with one addition. `WIREFRAMES.md` opts into Excalidraw rendering via a top-of-file marker like `<!-- excalidraw: WIREFRAMES.excalidraw -->`. `DESIGN.md` stays human-maintained.
68
67
 
69
- Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in the Stitch DESIGN.md spec format. These are separate files from the toolkit's `canon/DESIGN.md` and are not synced with it. Treat impeccable's pair as its own source of truth for its commands, and the toolkit's `canon/DESIGN.md` as the source other toolkit skills read.
68
+ Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in its own format. These are separate files from the toolkit's `canon/DESIGN.md` and are not synced with it. Treat impeccable's pair as its own source of truth for its commands, and the toolkit's `canon/DESIGN.md` as the source other toolkit skills read.
70
69
 
71
70
  ### Tools
72
71
 
73
- - Stitch via MCP at `stitch.googleapis.com/mcp`, Google's Gemini-powered design product. Agent-addressable through `generate_screen_from_text`, `edit_screens`, and `generate_variants`. Default pick for agent-driven visual generation. Free tier of 400 daily credits.
74
72
  - Excalidraw canvas server on localhost plus the community [`yctimlin/mcp_excalidraw`](https://github.com/yctimlin/mcp_excalidraw) MCP shim, for projects that need an agent to draw, read back, and revise a canvas. MIT-licensed, plaintext JSON scene format. `describe_scene` and `get_canvas_screenshot` let the agent verify its own layout before claiming it is correct.
75
73
  - Playwright MCP ([`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp)) for browser-side verification, driven off the accessibility tree rather than screenshots.
76
74
  - Chrome DevTools MCP ([`ChromeDevTools/chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp)) for live frontend debugging: performance traces, network inspection, and DOM and CSS inspection.
@@ -88,17 +86,15 @@ Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in the
88
86
  - Design decisions benefit from visual inspection
89
87
  - A single contributor owns both design and implementation
90
88
 
91
- ### Stitch vs Excalidraw vs Claude Design in tier 1
89
+ ### Excalidraw vs Claude Design in tier 1
92
90
 
93
- The three tools solve non-overlapping halves of the visual companion problem.
94
-
95
- Stitch via MCP generates polished prototypes from text and a provisioned design system, scripted from a Claude Code session. Output is human-facing and lives in Stitch. Free daily quota makes it the right pick for frequent iteration.
91
+ The two tools solve non-overlapping halves of the visual companion problem.
96
92
 
97
93
  Excalidraw gives the agent a canvas it can read back over MCP. Output is agent-facing and persists as a JSON file in the repo. Pick it when Claude Code needs to iterate on wireframes autonomously, not when a human needs polished review artifacts.
98
94
 
99
95
  Claude Design reads the raw codebase without prose curation and produces a polished handoff bundle with chat history. Weekly quota makes it expensive, so reserve it for the extraction pass or the final handoff moment, not daily iteration.
100
96
 
101
- Most tier 1 projects pick one. Projects with a messy codebase and no curated `DESIGN.md` may want Claude Design once for the initial extraction, then move to Stitch for ongoing work.
97
+ Most tier 1 projects pick one. Projects with a messy codebase and no curated `DESIGN.md` may want Claude Design once for the initial extraction.
102
98
 
103
99
  ## Tier 2: visual as source of truth
104
100
 
@@ -112,7 +108,6 @@ Design happens in a graphical tool. `canon/DESIGN.md` either regenerates from th
112
108
 
113
109
  - Figma desktop app with the official [Figma Dev Mode MCP](https://www.figma.com/blog/introducing-claude-code-to-figma/) for teams with a dedicated designer already on Figma. Bidirectional sync and Code to Canvas capture of a running Claude Code UI into editable Figma frames.
114
110
  - Claude Design with its Claude Code handoff bundle for teams without an existing Figma investment and for solo founders or PMs driving design themselves. One-way handoff, no bidirectional sync. See [Claude Design](../../wiki/claude/design.md).
115
- - Stitch via MCP as a low-cost complement to either, used for bulk screen generation driven by Claude Code.
116
111
  - Playwright and Chrome DevTools MCPs as in tier 1
117
112
 
118
113
  ### Skills
@@ -132,8 +127,6 @@ Figma wins when a dedicated designer already owns a Figma file, external collabo
132
127
 
133
128
  Claude Design wins when nobody on the team has a Figma workflow yet and the handoff bundle with chat history is the intended implementation path. Claude Design's handoff is one-way, and its quota limits daily iteration, so it is most useful for the initial extraction pass and the final handoff moment rather than day-to-day design work.
134
129
 
135
- Either of the above pairs cleanly with Stitch via MCP for agent-driven screen generation between the two anchor moments.
136
-
137
130
  ## Decision guide
138
131
 
139
132
  Four questions. Each yes weighs toward a higher tier.
@@ -7,12 +7,10 @@ description: Report file paths correctly for the reading surface and group them
7
7
  ## Reporting paths
8
8
 
9
9
  - After creating or modifying a file, include its path on its own line so the reader can open it. Do not paraphrase paths into prose ("the seeds folder", "your CLAUDE.md").
10
- - Read `CLAUDE_CODE_ENTRYPOINT` once, at the first response that emits a path, and reuse it for the rest of the session. The surface cannot change mid-session, so a second read only confirms the first.
11
- - When it reads `claude-desktop`, emit each path as a markdown link carrying the path as its text and an absolute `file://` URI as its target, resolving a relative path against the main project root to build that target. The desktop file tree hides dotted folders, so a bare path into one names a file the reader cannot reach.
12
- - On every other value, including unset, emit the path bare. A terminal emulator makes it clickable through its own path detection, and link markup defeats that.
13
- - Both forms govern a path emitted in a response. A path written into a markdown file follows the markdown standard instead, which your toolkit resolves by name, and which backticks a file reference and never repeats it as a link label.
10
+ - Emit a path in the form the surface's own instructions give, such as a markdown link where the harness asks for one, and bare where it gives none. A terminal makes a bare path clickable through its own path detection, and link markup defeats that.
11
+ - That form governs a path emitted in a response. A path written into a markdown file follows the markdown standard instead, which your toolkit resolves by name, and which backticks a file reference and never repeats it as a link label.
14
12
  - In a linked worktree (under `.claude/worktrees/<name>/`), emit the absolute path, since the editor is rooted at the main project root and a path relative to the worktree resolves nowhere there. Prefer the form a `PostToolUse` hook hands back after a write where one is installed.
15
13
 
16
14
  ## Grouping multiple files
17
15
 
18
- - When the response covers multiple files, group paths under headers: `**Created:**`, `**Modified:**`, `**Deleted:**`. Every path under them takes the form the entrypoint selected rather than the first alone. For single-file changes, the path on its own line is enough.
16
+ - When the response covers multiple files, group paths under headers: `**Created:**`, `**Modified:**`, `**Deleted:**`. Every path under them takes that form, not the first alone. For single-file changes, the path on its own line is enough.
@@ -9,7 +9,7 @@ paths:
9
9
 
10
10
  ## Reading a payload
11
11
 
12
- - Open a hook that reads a payload with a bounded `IFS= read -r -d '' -t 2 input`, exiting non-zero on an empty payload. An unbounded `cat` blocks forever against a hand run or an open socket, holding the session open with it. A test already checks this per file across both hook trees, but the rule stops the pattern being rediscovered rather than caught only after the fact.
12
+ - Open a hook that reads a payload with a bounded `IFS= read -r -d '' -t 2 input`, exiting non-zero on an empty payload. An unbounded `cat` blocks forever against a hand run or an open socket, holding the session open with it.
13
13
 
14
14
  ## Silencing output
15
15
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.16.0",
4
+ "version": "5.18.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -173,10 +173,17 @@ function architectureCounts(
173
173
  typeof record.entryCap === 'number' ? record.entryCap : undefined
174
174
 
175
175
  const revisitRequired = record.revisitRequired === true
176
+ const wordCap =
177
+ typeof record.wordCap === 'number' ? record.wordCap : undefined
178
+ const riskCap =
179
+ typeof record.riskCap === 'number' ? record.riskCap : undefined
180
+ const risksBullets =
181
+ typeof record.risksBullets === 'number' ? record.risksBullets : undefined
176
182
 
177
183
  let unverifiable = 0
178
184
  let unchecked = 0
179
185
  let lackingRevisit = 0
186
+ let overWords = 0
180
187
  for (const raw of decisions) {
181
188
  const entry = asObject(raw)
182
189
  const claim = entry?.claim
@@ -186,6 +193,13 @@ function architectureCounts(
186
193
  if (claim === 'neither') unverifiable += 1
187
194
  else if (checks === 0) unchecked += 1
188
195
  if (revisitRequired && entry?.revisit !== true) lackingRevisit += 1
196
+ if (
197
+ wordCap !== undefined &&
198
+ typeof entry?.words === 'number' &&
199
+ entry.words > wordCap
200
+ ) {
201
+ overWords += 1
202
+ }
189
203
  }
190
204
 
191
205
  return {
@@ -201,11 +215,39 @@ function architectureCounts(
201
215
  }),
202
216
  // Absent on a record stating no revisit clause, for the same reason.
203
217
  ...(revisitRequired && { recordMissingRevisit: lackingRevisit }),
218
+ // Absent on a record stating no word cap or no bullet cap, for the same
219
+ // reason, and the bullet key absent too on a record with no Risks section.
220
+ ...(wordCap !== undefined && { recordOverWords: overWords }),
221
+ ...(riskCap !== undefined &&
222
+ risksBullets !== undefined && {
223
+ recordOverRisks: risksBullets > riskCap ? 1 : 0,
224
+ }),
204
225
  recordUnverifiable: unverifiable,
205
226
  recordUnchecked: unchecked,
206
227
  }
207
228
  }
208
229
 
230
+ /**
231
+ * Reads the requirements record's one cap, or nothing when the record is
232
+ * absent, states no cap, or the run never opened it.
233
+ *
234
+ * The key is optional rather than required the way `architecture` is, so a
235
+ * record from a binary predating it still counts its other findings.
236
+ */
237
+ function requirementsCounts(
238
+ root: Record<string, unknown>,
239
+ ): Record<string, number> {
240
+ const record = asObject(root.requirements)
241
+ if (
242
+ record === undefined ||
243
+ typeof record.words !== 'number' ||
244
+ typeof record.wordCap !== 'number'
245
+ ) {
246
+ return {}
247
+ }
248
+ return { requirementsOverWords: record.words > record.wordCap ? 1 : 0 }
249
+ }
250
+
209
251
  /**
210
252
  * The retained key for each verdict the sweep can return. A record rather than
211
253
  * a list, so the compiler fails here when the classifier gains a verdict,
@@ -261,6 +303,7 @@ function contextCounts(record: unknown): Record<string, number> | undefined {
261
303
 
262
304
  const architecture = architectureCounts(root)
263
305
  if (architecture === undefined) return undefined
306
+ const requirements = requirementsCounts(root)
264
307
 
265
308
  const counts = allOf({
266
309
  unresolvedCitations: lengthOf(asObject(root.citations)?.unresolved),
@@ -271,7 +314,9 @@ function contextCounts(record: unknown): Record<string, number> | undefined {
271
314
  })
272
315
 
273
316
  if (counts === undefined) return undefined
274
- return architecture === 'absent' ? counts : { ...counts, ...architecture }
317
+ return architecture === 'absent'
318
+ ? { ...counts, ...requirements }
319
+ : { ...counts, ...architecture, ...requirements }
275
320
  }
276
321
 
277
322
  function markdownCounts(record: unknown): Record<string, number> | undefined {
@@ -90,6 +90,58 @@ interface Collected {
90
90
  text: string
91
91
  }
92
92
 
93
+ /**
94
+ * Called on each element the address counts, with its index. What it returns
95
+ * runs at the element's end tag, since a second `onEndTag` on one element
96
+ * replaces the first rather than adding to it.
97
+ */
98
+ export type ElementVisitor = (
99
+ element: HTMLRewriterTypes.Element,
100
+ index: number,
101
+ ) => (() => void) | undefined
102
+
103
+ /**
104
+ * Registers the one counting rule both the reader and the edit writer follow,
105
+ * so an index means the same element to each.
106
+ */
107
+ export function countElements(
108
+ rewriter: HTMLRewriter,
109
+ visit: ElementVisitor,
110
+ ): HTMLRewriter {
111
+ let count = 0
112
+ /* A template's content is a fragment `querySelectorAll` never reaches. */
113
+ let templateDepth = 0
114
+
115
+ return rewriter.on('*', {
116
+ element(element) {
117
+ const tag = element.tagName.toLowerCase()
118
+ if (templateDepth > 0) {
119
+ if (tag === 'template') {
120
+ templateDepth += 1
121
+ element.onEndTag(() => {
122
+ templateDepth -= 1
123
+ })
124
+ }
125
+ return
126
+ }
127
+ const end = visit(element, count)
128
+ count += 1
129
+ if (!element.canHaveContent || element.selfClosing) return
130
+ const isTemplate = tag === 'template'
131
+ if (isTemplate) templateDepth += 1
132
+ element.onEndTag(() => {
133
+ if (isTemplate) templateDepth -= 1
134
+ end?.()
135
+ })
136
+ },
137
+ })
138
+ }
139
+
140
+ /** Whether an element's content is raw text rather than parsed markup. */
141
+ export function isRawText(tag: string): boolean {
142
+ return RAW_TEXT.has(tag)
143
+ }
144
+
93
145
  /**
94
146
  * Every element the file states, in document order. Text is gathered into
95
147
  * each open element, which an element closed only by implication keeps open to
@@ -99,8 +151,6 @@ export function sourceElements(html: string): SourceElement[] {
99
151
  const elements: Collected[] = []
100
152
  const open: number[] = []
101
153
  let rawDepth = 0
102
- /* A template's content is a fragment `querySelectorAll` never reaches. */
103
- let templateDepth = 0
104
154
 
105
155
  const appendText = (text: string) => {
106
156
  for (const index of open) {
@@ -111,44 +161,28 @@ export function sourceElements(html: string): SourceElement[] {
111
161
  }
112
162
  }
113
163
 
114
- new HTMLRewriter()
115
- .on('*', {
116
- element(element) {
117
- const tag = element.tagName.toLowerCase()
118
- if (templateDepth > 0) {
119
- if (tag === 'template') {
120
- templateDepth += 1
121
- element.onEndTag(() => {
122
- templateDepth -= 1
123
- })
124
- }
125
- return
126
- }
127
- const index = elements.length
128
- /* A tag boundary separates words the way a rendered block would. */
129
- appendText(' ')
130
- elements.push({
131
- tag,
132
- classes: (element.getAttribute('class') ?? '')
133
- .split(/\s+/)
134
- .filter(Boolean),
135
- text: '',
136
- })
137
- if (!element.canHaveContent || element.selfClosing) return
138
- const isRaw = RAW_TEXT.has(tag)
139
- const isTemplate = tag === 'template'
140
- open.push(index)
141
- if (isRaw) rawDepth += 1
142
- if (isTemplate) templateDepth += 1
143
- element.onEndTag(() => {
144
- const at = open.lastIndexOf(index)
145
- if (at !== -1) open.splice(at, 1)
146
- if (isRaw) rawDepth -= 1
147
- if (isTemplate) templateDepth -= 1
148
- appendText(' ')
149
- })
150
- },
164
+ countElements(new HTMLRewriter(), (element, index) => {
165
+ const tag = element.tagName.toLowerCase()
166
+ /* A tag boundary separates words the way a rendered block would. */
167
+ appendText(' ')
168
+ elements.push({
169
+ tag,
170
+ classes: (element.getAttribute('class') ?? '')
171
+ .split(/\s+/)
172
+ .filter(Boolean),
173
+ text: '',
151
174
  })
175
+ if (!element.canHaveContent || element.selfClosing) return undefined
176
+ const isRaw = isRawText(tag)
177
+ open.push(index)
178
+ if (isRaw) rawDepth += 1
179
+ return () => {
180
+ const at = open.lastIndexOf(index)
181
+ if (at !== -1) open.splice(at, 1)
182
+ if (isRaw) rawDepth -= 1
183
+ appendText(' ')
184
+ }
185
+ })
152
186
  .onDocument({
153
187
  text(chunk) {
154
188
  if (rawDepth === 0) appendText(chunk.text)
@@ -1,82 +1,286 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
- import { elementAt } from '@/canvas/address'
3
+ import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
4
4
  import {
5
5
  currentPage,
6
+ editElement,
7
+ editRefusal,
8
+ type FrameRef,
6
9
  frameDocuments,
7
10
  frameKey,
11
+ frameVersions,
12
+ pendingEdit,
13
+ savedEdit,
8
14
  selectedElement,
9
15
  selectedFrame,
16
+ tokens,
10
17
  writeError,
11
18
  } from '@/canvas/client/state'
12
19
 
13
20
  const FIELDS = ['x', 'y', 'width', 'height'] as const
14
21
 
22
+ /** Elements the parser never gives content, so they hold no text to edit. */
23
+ const VOID = new Set([
24
+ 'area',
25
+ 'base',
26
+ 'br',
27
+ 'col',
28
+ 'embed',
29
+ 'hr',
30
+ 'img',
31
+ 'input',
32
+ 'link',
33
+ 'meta',
34
+ 'source',
35
+ 'track',
36
+ 'wbr',
37
+ ])
38
+
39
+ interface StyleField {
40
+ readonly label: string
41
+ readonly property: string
42
+ readonly isColor?: boolean
43
+ }
44
+
45
+ /**
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.
48
+ */
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' },
59
+ ]
60
+
15
61
  interface Row {
16
62
  readonly name: string
17
63
  readonly value: string
18
64
  }
19
65
 
20
- /**
21
- * The computed values the browser settled on, which is what the operator sees
22
- * rather than what any one stylesheet states.
23
- */
24
- function styleRows(element: Element): Row[] {
66
+ /** Where the element sits in its frame, which the panel shows and never edits. */
67
+ function positionRows(element: Element): Row[] {
25
68
  const rect = element.getBoundingClientRect()
26
- const view = element.ownerDocument.defaultView
27
- const style = view?.getComputedStyle(element)
28
- const rows: Row[] = [
69
+ const font = element.ownerDocument.defaultView
70
+ ?.getComputedStyle(element)
71
+ .getPropertyValue('font-family')
72
+ return [
29
73
  { name: 'x', value: String(Math.round(rect.left)) },
30
74
  { name: 'y', value: String(Math.round(rect.top)) },
31
- { name: 'width', value: String(Math.round(rect.width)) },
32
- { name: 'height', value: String(Math.round(rect.height)) },
33
- ]
34
- if (!style) return rows
35
- return [
36
- ...rows,
37
- { name: 'color', value: style.color },
38
- { name: 'background', value: style.backgroundColor },
39
- { name: 'font', value: style.fontFamily },
40
- { name: 'size', value: style.fontSize },
41
- { name: 'weight', value: style.fontWeight },
75
+ ...(font ? [{ name: 'font', value: font }] : []),
42
76
  ]
43
77
  }
44
78
 
79
+ /**
80
+ * What the element states inline, else the value the browser settled on, so
81
+ * an edit starts from what the operator sees.
82
+ */
83
+ function currentValue(element: Element, property: string): string {
84
+ const inline =
85
+ 'style' in element
86
+ ? (element as HTMLElement).style.getPropertyValue(property)
87
+ : ''
88
+ if (inline) return inline
89
+ return (
90
+ element.ownerDocument.defaultView
91
+ ?.getComputedStyle(element)
92
+ .getPropertyValue(property) ?? ''
93
+ )
94
+ }
95
+
96
+ function holdsTextAlone(element: Element): boolean {
97
+ const tag = element.tagName.toLowerCase()
98
+ return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
99
+ }
100
+
45
101
  function elementName(element: Element): string {
46
102
  const tag = element.tagName.toLowerCase()
47
103
  return [tag, ...element.classList].join('.')
48
104
  }
49
105
 
50
- /** Read-only in this slice, since editing an element is the edit slice's work. */
106
+ interface FieldProps {
107
+ readonly label: string
108
+ readonly initial: string
109
+ readonly isBusy: boolean
110
+ readonly onCommit: (value: string) => void
111
+ }
112
+
113
+ /**
114
+ * Commits on change, which a text input fires on Enter or on leaving it, and
115
+ * sends nothing when the value is what it started at. Escape puts it back.
116
+ */
117
+ function Field({ label, initial, isBusy, onCommit }: FieldProps): JSX.Element {
118
+ return (
119
+ <input
120
+ class="field"
121
+ type="text"
122
+ aria-label={label}
123
+ title={initial}
124
+ defaultValue={initial}
125
+ disabled={isBusy}
126
+ spellcheck={false}
127
+ onChange={(event) => {
128
+ const value = event.currentTarget.value.trim()
129
+ if (value !== initial.trim()) onCommit(value)
130
+ }}
131
+ onKeyDown={(event) => {
132
+ if (event.key === 'Escape') event.currentTarget.value = initial
133
+ }}
134
+ />
135
+ )
136
+ }
137
+
138
+ function TokenPicker({
139
+ label,
140
+ initial,
141
+ isBusy,
142
+ onCommit,
143
+ }: FieldProps): JSX.Element | null {
144
+ const colors =
145
+ tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
146
+ if (colors.length === 0) return null
147
+ return (
148
+ <select
149
+ class="token-picker"
150
+ aria-label={`${label} token`}
151
+ title="Pick a project token"
152
+ value={
153
+ colors.some((token) => initial === `var(${token.name})`) ? initial : ''
154
+ }
155
+ disabled={isBusy}
156
+ onChange={(event) => {
157
+ const { value } = event.currentTarget
158
+ if (value !== '' && value !== initial) onCommit(value)
159
+ }}
160
+ >
161
+ <option value="">Token</option>
162
+ {colors.map((token) => (
163
+ <option key={token.name} value={`var(${token.name})`}>
164
+ {token.name}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ )
169
+ }
170
+
171
+ function ElementFields({
172
+ node,
173
+ doc,
174
+ frameRef,
175
+ frameKey: key,
176
+ }: {
177
+ readonly node: Element
178
+ readonly doc: Document
179
+ readonly frameRef: FrameRef
180
+ readonly frameKey: string
181
+ }): JSX.Element {
182
+ const address = addressOf(doc, node)
183
+ const pending = pendingEdit.value
184
+ const isBusy = pending?.key === key && pending.index === address?.index
185
+ const commit = (property: string) => (value: string) => {
186
+ if (address) void editElement(frameRef, key, address, property, value)
187
+ }
188
+ const isTextOnly = holdsTextAlone(node)
189
+ const text = node.textContent ?? ''
190
+
191
+ return (
192
+ <dl class="box styles">
193
+ {positionRows(node).map((row) => (
194
+ <div key={row.name} class="box-row">
195
+ <dt>{row.name}</dt>
196
+ <dd title={row.value}>{row.value}</dd>
197
+ </div>
198
+ ))}
199
+ <div class="box-row">
200
+ <dt>text</dt>
201
+ <dd title={excerpt(text)}>
202
+ {isTextOnly ? (
203
+ <Field
204
+ label="text"
205
+ initial={text}
206
+ isBusy={isBusy}
207
+ onCommit={commit('text')}
208
+ />
209
+ ) : (
210
+ excerpt(text)
211
+ )}
212
+ </dd>
213
+ </div>
214
+ {STYLE_FIELDS.map((field) => {
215
+ const initial = currentValue(node, field.property)
216
+ return (
217
+ <div key={field.property} class="box-row">
218
+ <dt>{field.label}</dt>
219
+ <dd class="field-cell">
220
+ <Field
221
+ label={field.label}
222
+ initial={initial}
223
+ isBusy={isBusy}
224
+ onCommit={commit(field.property)}
225
+ />
226
+ {field.isColor ? (
227
+ <TokenPicker
228
+ label={field.label}
229
+ initial={initial}
230
+ isBusy={isBusy}
231
+ onCommit={commit(field.property)}
232
+ />
233
+ ) : null}
234
+ </dd>
235
+ </div>
236
+ )
237
+ })}
238
+ </dl>
239
+ )
240
+ }
241
+
51
242
  function ElementDetails(): JSX.Element | null {
52
243
  const picked = selectedElement.value
53
244
  const page = currentPage.value
54
245
  if (!picked || !page) return null
55
246
  const { frame, element } = picked
56
- const doc = frameDocuments.value.get(frameKey(page.name, frame))
247
+ const key = frameKey(page.name, frame)
248
+ const doc = frameDocuments.value.get(key)
57
249
  const node = doc && !element.stale ? elementAt(doc, element) : undefined
250
+ const saved = savedEdit.value
251
+ const isSaved = saved?.key === key && saved.index === element.index
58
252
 
59
253
  return (
60
254
  <section aria-label="Element">
61
- <h2 class="section-label">Element</h2>
255
+ <h2 class="section-label">
256
+ Element
257
+ {isSaved ? (
258
+ <span class="saved" role="status">
259
+ Saved
260
+ </span>
261
+ ) : null}
262
+ </h2>
62
263
  {element.stale ? (
63
264
  <p class="notice">
64
265
  The frame changed since this element was picked, so its index may name
65
266
  another element now. Pick it again.
66
267
  </p>
67
- ) : node ? (
268
+ ) : doc && node ? (
68
269
  <>
69
270
  <p class="detail" title={elementName(node)}>
70
271
  {elementName(node)}
71
272
  </p>
72
- <dl class="box styles">
73
- {styleRows(node).map((row) => (
74
- <div key={row.name} class="box-row">
75
- <dt>{row.name}</dt>
76
- <dd title={row.value}>{row.value}</dd>
77
- </div>
78
- ))}
79
- </dl>
273
+ {/*
274
+ * Keyed by the frame's version and the element, so a reload drops
275
+ * any value typed against the document it replaced.
276
+ */}
277
+ <ElementFields
278
+ key={`${frameVersions.value.get(key) ?? 0}:${element.index}`}
279
+ node={node}
280
+ doc={doc}
281
+ frameRef={{ page: page.name, frame: frame.name }}
282
+ frameKey={key}
283
+ />
80
284
  </>
81
285
  ) : (
82
286
  <p class="empty">Loading the frame to read this element</p>
@@ -85,7 +289,6 @@ function ElementDetails(): JSX.Element | null {
85
289
  )
86
290
  }
87
291
 
88
- /** Read-only in this slice, since editing a box is the edit slice's work. */
89
292
  export function Inspector(): JSX.Element {
90
293
  const frame = selectedFrame.value
91
294
  return (
@@ -116,6 +319,11 @@ export function Inspector(): JSX.Element {
116
319
  ) : null}
117
320
  </section>
118
321
  <ElementDetails />
322
+ {editRefusal.value ? (
323
+ <p class="notice" role="alert">
324
+ {editRefusal.value}
325
+ </p>
326
+ ) : null}
119
327
  </>
120
328
  )
121
329
  }