@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +9 -9
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/demo.ts +2 -0
- package/src/commands/teach.ts +3 -1
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/teach/workspace.ts +61 -6
- package/standards/architecture.md +13 -7
- package/standards/figures.md +1 -0
- package/standards/requirements.md +4 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
###
|
|
89
|
+
### Excalidraw vs Claude Design in tier 1
|
|
92
90
|
|
|
93
|
-
The
|
|
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
|
|
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
|
-
-
|
|
11
|
-
-
|
|
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
|
|
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.
|
|
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
package/src/audits/catalog.ts
CHANGED
|
@@ -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'
|
|
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 {
|
package/src/canvas/address.ts
CHANGED
|
@@ -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
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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">
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|