@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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
countElements,
|
|
5
|
+
type ElementAddress,
|
|
6
|
+
isRawText,
|
|
7
|
+
resolveAddress,
|
|
8
|
+
sourceElements,
|
|
9
|
+
} from '@/canvas/address'
|
|
10
|
+
import {
|
|
11
|
+
type ContentRefusal,
|
|
12
|
+
canvasDir,
|
|
13
|
+
contentHash,
|
|
14
|
+
readPage,
|
|
15
|
+
readSelection,
|
|
16
|
+
TEMP_SUFFIX,
|
|
17
|
+
withFileLock,
|
|
18
|
+
writeSelection,
|
|
19
|
+
} from '@/canvas/content'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The basic set an operator edits by hand. Each is a CSS property written into
|
|
23
|
+
* the element's inline style, except `text`, which replaces its text.
|
|
24
|
+
*/
|
|
25
|
+
export const STYLE_PROPERTIES = [
|
|
26
|
+
'color',
|
|
27
|
+
'background-color',
|
|
28
|
+
'font-size',
|
|
29
|
+
'font-weight',
|
|
30
|
+
'width',
|
|
31
|
+
'height',
|
|
32
|
+
'padding',
|
|
33
|
+
'gap',
|
|
34
|
+
'flex-direction',
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
|
|
38
|
+
|
|
39
|
+
export type EditProperty = StyleProperty | 'text'
|
|
40
|
+
|
|
41
|
+
export interface EditChange {
|
|
42
|
+
readonly property: string
|
|
43
|
+
/** Empty drops a style property, and sets an element's text to nothing. */
|
|
44
|
+
readonly value: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type EditRefusal = ContentRefusal | 'invalid-edit' | 'not-text-only'
|
|
48
|
+
|
|
49
|
+
export interface EditRefused {
|
|
50
|
+
readonly ok: false
|
|
51
|
+
readonly reason: EditRefusal
|
|
52
|
+
readonly detail: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AppliedEdit = { readonly ok: true; readonly html: string }
|
|
56
|
+
|
|
57
|
+
export type FrameEdit =
|
|
58
|
+
| {
|
|
59
|
+
readonly ok: true
|
|
60
|
+
readonly page: string
|
|
61
|
+
readonly frame: string
|
|
62
|
+
readonly file: string
|
|
63
|
+
/** The file's hash after the edit, which the next edit is made from. */
|
|
64
|
+
readonly hash: string
|
|
65
|
+
}
|
|
66
|
+
| EditRefused
|
|
67
|
+
|
|
68
|
+
const MAX_VALUE_LENGTH = 200
|
|
69
|
+
|
|
70
|
+
/** Anything that would end the declaration and start another. */
|
|
71
|
+
const DECLARATION_BREAK = /[;{}<>\n\r]/
|
|
72
|
+
|
|
73
|
+
const BOM = ''
|
|
74
|
+
|
|
75
|
+
function refuse(reason: EditRefusal, detail: string): EditRefused {
|
|
76
|
+
return { ok: false, reason, detail }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isStyleProperty(property: string): property is StyleProperty {
|
|
80
|
+
return (STYLE_PROPERTIES as readonly string[]).includes(property)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isEditProperty(property: string): property is EditProperty {
|
|
84
|
+
return property === 'text' || isStyleProperty(property)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkChange(change: EditChange): EditRefused | undefined {
|
|
88
|
+
if (!isEditProperty(change.property)) {
|
|
89
|
+
return refuse(
|
|
90
|
+
'invalid-edit',
|
|
91
|
+
`${change.property} is not editable here. Edit one of text, ${STYLE_PROPERTIES.join(', ')}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
if (change.value.length > MAX_VALUE_LENGTH) {
|
|
95
|
+
return refuse(
|
|
96
|
+
'invalid-edit',
|
|
97
|
+
`the value runs past ${MAX_VALUE_LENGTH} characters`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (change.property !== 'text' && DECLARATION_BREAK.test(change.value)) {
|
|
101
|
+
return refuse(
|
|
102
|
+
'invalid-edit',
|
|
103
|
+
`${change.property} takes one value, with no ; { } < > or line break`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Splits a style attribute at each semicolon outside parentheses and quotes,
|
|
111
|
+
* keeping every segment's own spacing so an untouched one is written back as
|
|
112
|
+
* it was.
|
|
113
|
+
*/
|
|
114
|
+
function declarations(style: string): string[] {
|
|
115
|
+
const segments: string[] = []
|
|
116
|
+
let depth = 0
|
|
117
|
+
let quote: string | undefined
|
|
118
|
+
let start = 0
|
|
119
|
+
for (let at = 0; at < style.length; at += 1) {
|
|
120
|
+
const char = style[at]
|
|
121
|
+
if (quote) {
|
|
122
|
+
if (char === quote) quote = undefined
|
|
123
|
+
} else if (char === '"' || char === "'") quote = char
|
|
124
|
+
else if (char === '(') depth += 1
|
|
125
|
+
else if (char === ')') depth = Math.max(0, depth - 1)
|
|
126
|
+
else if (char === ';' && depth === 0) {
|
|
127
|
+
segments.push(style.slice(start, at))
|
|
128
|
+
start = at + 1
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
segments.push(style.slice(start))
|
|
132
|
+
return segments
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function propertyOf(segment: string): string {
|
|
136
|
+
return segment.split(':', 1)[0]?.trim().toLowerCase() ?? ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sets one property in a style attribute, or drops it on an empty value. The
|
|
141
|
+
* first declaration of it is replaced in place and any later one dropped, so
|
|
142
|
+
* nothing further in the attribute overrides the edit. Undefined means no
|
|
143
|
+
* style is left.
|
|
144
|
+
*/
|
|
145
|
+
export function setDeclaration(
|
|
146
|
+
style: string,
|
|
147
|
+
property: StyleProperty,
|
|
148
|
+
value: string,
|
|
149
|
+
): string | undefined {
|
|
150
|
+
const segments = declarations(style)
|
|
151
|
+
const matches = segments.filter((segment) => propertyOf(segment) === property)
|
|
152
|
+
const declaration = `${property}: ${value.trim()}`
|
|
153
|
+
|
|
154
|
+
let next: string[]
|
|
155
|
+
if (value.trim() === '') {
|
|
156
|
+
next = segments.filter((segment) => propertyOf(segment) !== property)
|
|
157
|
+
} else if (matches.length > 0) {
|
|
158
|
+
let isReplaced = false
|
|
159
|
+
next = segments.flatMap((segment) => {
|
|
160
|
+
if (propertyOf(segment) !== property) return [segment]
|
|
161
|
+
if (isReplaced) return []
|
|
162
|
+
isReplaced = true
|
|
163
|
+
const lead = segment.match(/^\s*/)?.[0] ?? ''
|
|
164
|
+
return [`${lead}${declaration}`]
|
|
165
|
+
})
|
|
166
|
+
} else {
|
|
167
|
+
const kept = style.replace(/[\s;]*$/, '')
|
|
168
|
+
return kept === '' ? declaration : `${kept}; ${declaration}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (next.every((segment) => segment.trim() === '')) return undefined
|
|
172
|
+
return next.join(';').replace(/^[\s;]+/, '')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether the element at an index holds text alone, so replacing its text
|
|
177
|
+
* flattens no nested markup. An element closed only by implication stays open
|
|
178
|
+
* to the end and reads as holding everything after it, which refuses.
|
|
179
|
+
*/
|
|
180
|
+
function holdsTextAlone(html: string, index: number): boolean {
|
|
181
|
+
let isOpen = false
|
|
182
|
+
let hasChild = false
|
|
183
|
+
countElements(new HTMLRewriter(), (_element, at) => {
|
|
184
|
+
if (isOpen) hasChild = true
|
|
185
|
+
if (at !== index) return undefined
|
|
186
|
+
isOpen = true
|
|
187
|
+
return () => {
|
|
188
|
+
isOpen = false
|
|
189
|
+
}
|
|
190
|
+
}).transform(html)
|
|
191
|
+
return !hasChild
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Applies one change to the element the address names and leaves every other
|
|
196
|
+
* byte of the file as it was. The address is checked against the file first,
|
|
197
|
+
* the same way a pick is.
|
|
198
|
+
*/
|
|
199
|
+
export function applyEdit(
|
|
200
|
+
source: string,
|
|
201
|
+
address: ElementAddress,
|
|
202
|
+
change: EditChange,
|
|
203
|
+
): AppliedEdit | EditRefused {
|
|
204
|
+
const invalid = checkChange(change)
|
|
205
|
+
if (invalid) return invalid
|
|
206
|
+
|
|
207
|
+
/* The rewriter drops a leading byte order mark, so it is put back after. */
|
|
208
|
+
const bom = source.startsWith(BOM) ? BOM : ''
|
|
209
|
+
const html = source.slice(bom.length)
|
|
210
|
+
|
|
211
|
+
const check = resolveAddress(html, address)
|
|
212
|
+
if (!check.ok) return refuse(check.reason, check.detail)
|
|
213
|
+
|
|
214
|
+
const { property, value } = change
|
|
215
|
+
if (property === 'text') {
|
|
216
|
+
if (isRawText(address.tag) || !holdsTextAlone(html, address.index)) {
|
|
217
|
+
return refuse(
|
|
218
|
+
'not-text-only',
|
|
219
|
+
`<${address.tag}> holds other elements or raw content, so its text is not edited as one string`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let voidRefused = false
|
|
225
|
+
const rewritten = countElements(new HTMLRewriter(), (element, at) => {
|
|
226
|
+
if (at !== address.index) return undefined
|
|
227
|
+
if (property === 'text') {
|
|
228
|
+
if (!element.canHaveContent || element.selfClosing) {
|
|
229
|
+
voidRefused = true
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
element.setInnerContent(value, { html: false })
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
if (!isStyleProperty(property)) return undefined
|
|
236
|
+
const style = setDeclaration(
|
|
237
|
+
element.getAttribute('style') ?? '',
|
|
238
|
+
property,
|
|
239
|
+
value,
|
|
240
|
+
)
|
|
241
|
+
if (style === undefined) element.removeAttribute('style')
|
|
242
|
+
else element.setAttribute('style', style)
|
|
243
|
+
return undefined
|
|
244
|
+
}).transform(html)
|
|
245
|
+
|
|
246
|
+
if (voidRefused) {
|
|
247
|
+
return refuse('not-text-only', `<${address.tag}> cannot hold text`)
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, html: `${bom}${rewritten}` }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Edits one element of a frame file under the file's lock. An address carrying
|
|
254
|
+
* a hash the file no longer has is refused rather than applied at an index the
|
|
255
|
+
* change may have shifted. A selection that was fresh before the edit is
|
|
256
|
+
* restamped after it, since the edit moves no element.
|
|
257
|
+
*/
|
|
258
|
+
export function editFrame(
|
|
259
|
+
root: string,
|
|
260
|
+
page: string,
|
|
261
|
+
frame: string,
|
|
262
|
+
address: ElementAddress,
|
|
263
|
+
change: EditChange,
|
|
264
|
+
): FrameEdit {
|
|
265
|
+
const invalid = checkChange(change)
|
|
266
|
+
if (invalid) return invalid
|
|
267
|
+
|
|
268
|
+
const onPage = readPage(root, page)
|
|
269
|
+
if (!onPage) return refuse('no-page', `page ${page} does not exist`)
|
|
270
|
+
const found = onPage.frames.find((candidate) => candidate.name === frame)
|
|
271
|
+
if (!found) {
|
|
272
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const path = join(canvasDir(root), page, found.file)
|
|
276
|
+
return withFileLock(path, (): FrameEdit => {
|
|
277
|
+
const bytes = readFileSync(path)
|
|
278
|
+
if (address.hash !== undefined && address.hash !== contentHash(bytes)) {
|
|
279
|
+
return refuse(
|
|
280
|
+
'stale-address',
|
|
281
|
+
`${page}/${frame} changed after this edit was made, so nothing was written`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
const before = readSelection(root)
|
|
285
|
+
const isSelectionFresh =
|
|
286
|
+
before?.page === page &&
|
|
287
|
+
before.frame === frame &&
|
|
288
|
+
before.element !== undefined &&
|
|
289
|
+
!before.element.stale
|
|
290
|
+
|
|
291
|
+
const applied = applyEdit(bytes.toString('utf8'), address, change)
|
|
292
|
+
if (!applied.ok) return applied
|
|
293
|
+
|
|
294
|
+
const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
|
|
295
|
+
writeFileSync(temp, applied.html)
|
|
296
|
+
renameSync(temp, path)
|
|
297
|
+
|
|
298
|
+
if (isSelectionFresh && before.element) {
|
|
299
|
+
writeSelection(root, {
|
|
300
|
+
page,
|
|
301
|
+
frame,
|
|
302
|
+
element: {
|
|
303
|
+
index: before.element.index,
|
|
304
|
+
tag: before.element.tag,
|
|
305
|
+
count: sourceElements(applied.html).length,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
page,
|
|
312
|
+
frame,
|
|
313
|
+
file: found.file,
|
|
314
|
+
hash: contentHash(applied.html),
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Edits the element at an index of the file as it stands, for a caller that
|
|
321
|
+
* holds no served frame to take an address from. The address it builds
|
|
322
|
+
* carries the hash it read, so a write landing between the read and the lock
|
|
323
|
+
* still refuses.
|
|
324
|
+
*/
|
|
325
|
+
export function editFrameAtIndex(
|
|
326
|
+
root: string,
|
|
327
|
+
page: string,
|
|
328
|
+
frame: string,
|
|
329
|
+
index: number,
|
|
330
|
+
change: EditChange,
|
|
331
|
+
): FrameEdit {
|
|
332
|
+
const onPage = readPage(root, page)
|
|
333
|
+
if (!onPage) return refuse('no-page', `page ${page} does not exist`)
|
|
334
|
+
const found = onPage.frames.find((candidate) => candidate.name === frame)
|
|
335
|
+
if (!found) {
|
|
336
|
+
return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const bytes = readFileSync(join(canvasDir(root), page, found.file))
|
|
340
|
+
const elements = sourceElements(bytes.toString('utf8'))
|
|
341
|
+
const element = Number.isInteger(index) ? elements[index] : undefined
|
|
342
|
+
if (!element) {
|
|
343
|
+
return refuse(
|
|
344
|
+
'invalid-address',
|
|
345
|
+
`${page}/${frame} holds elements 0 to ${elements.length - 1}, so ${index} names none`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
return editFrame(
|
|
349
|
+
root,
|
|
350
|
+
page,
|
|
351
|
+
frame,
|
|
352
|
+
{
|
|
353
|
+
index,
|
|
354
|
+
tag: element.tag,
|
|
355
|
+
count: elements.length,
|
|
356
|
+
hash: contentHash(bytes),
|
|
357
|
+
},
|
|
358
|
+
change,
|
|
359
|
+
)
|
|
360
|
+
}
|
package/src/canvas/server.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
TEMP_SUFFIX,
|
|
16
16
|
writeSelection,
|
|
17
17
|
} from '@/canvas/content'
|
|
18
|
-
import {
|
|
18
|
+
import { type EditRefused, editFrame } from '@/canvas/edit'
|
|
19
|
+
import {
|
|
20
|
+
resolveFrameTokens,
|
|
21
|
+
type TokenOptions,
|
|
22
|
+
tokenGroups,
|
|
23
|
+
} from '@/canvas/tokens'
|
|
19
24
|
import { buildDesignCss } from '@/design/css'
|
|
20
25
|
import {
|
|
21
26
|
bindFirstFree,
|
|
@@ -140,7 +145,9 @@ function json(body: unknown, status = 200): Response {
|
|
|
140
145
|
})
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
const REFUSAL_STATUS: Record<
|
|
148
|
+
const REFUSAL_STATUS: Record<EditRefused['reason'], number> = {
|
|
149
|
+
'invalid-edit': 400,
|
|
150
|
+
'not-text-only': 409,
|
|
144
151
|
'invalid-name': 400,
|
|
145
152
|
'invalid-size': 400,
|
|
146
153
|
'invalid-position': 400,
|
|
@@ -154,7 +161,7 @@ const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
|
|
|
154
161
|
'stale-address': 409,
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
function refusal(refused: ContentRefused): Response {
|
|
164
|
+
function refusal(refused: ContentRefused | EditRefused): Response {
|
|
158
165
|
return json(refused, REFUSAL_STATUS[refused.reason])
|
|
159
166
|
}
|
|
160
167
|
|
|
@@ -351,11 +358,11 @@ export function startCanvas(
|
|
|
351
358
|
'/': options.shell,
|
|
352
359
|
'/api/pages': guarded(() => {
|
|
353
360
|
const resolved = resolveFrameTokens(root, tokens)
|
|
354
|
-
const { css
|
|
361
|
+
const { css, ...source } = resolved
|
|
355
362
|
const selected = readSelection(root)
|
|
356
363
|
return json({
|
|
357
364
|
pages: listPages(root),
|
|
358
|
-
tokens: source,
|
|
365
|
+
tokens: { ...source, groups: tokenGroups(css) },
|
|
359
366
|
selection: selected && {
|
|
360
367
|
page: selected.page,
|
|
361
368
|
frame: selected.frame,
|
|
@@ -389,6 +396,44 @@ export function startCanvas(
|
|
|
389
396
|
}),
|
|
390
397
|
),
|
|
391
398
|
},
|
|
399
|
+
'/api/frames/edit': {
|
|
400
|
+
POST: guarded(
|
|
401
|
+
mutation((body) => {
|
|
402
|
+
if (
|
|
403
|
+
!isRecord(body) ||
|
|
404
|
+
typeof body.page !== 'string' ||
|
|
405
|
+
typeof body.frame !== 'string' ||
|
|
406
|
+
typeof body.property !== 'string' ||
|
|
407
|
+
typeof body.value !== 'string'
|
|
408
|
+
) {
|
|
409
|
+
return badBody(
|
|
410
|
+
'send page, frame, element, property, and value',
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
const { element } = body
|
|
414
|
+
/*
|
|
415
|
+
* The hash is what keeps an edit off an element the file has
|
|
416
|
+
* since moved, so the shell's edit never goes without it.
|
|
417
|
+
*/
|
|
418
|
+
if (!isAddressShape(element) || element.hash === undefined) {
|
|
419
|
+
return badBody(
|
|
420
|
+
'send an element as index, tag, count, and the hash it was served with',
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
const outcome = editFrame(
|
|
424
|
+
root,
|
|
425
|
+
body.page,
|
|
426
|
+
body.frame,
|
|
427
|
+
element,
|
|
428
|
+
{
|
|
429
|
+
property: body.property,
|
|
430
|
+
value: body.value,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
},
|
|
392
437
|
'/api/selection': {
|
|
393
438
|
POST: guarded(
|
|
394
439
|
mutation((body) => {
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -29,6 +29,88 @@ const BASE_FILE = 'base.css'
|
|
|
29
29
|
|
|
30
30
|
const NONE_NOTICE = `No token stylesheet, so frames render unstyled. Run canon design install to add ${join(DESIGN_INSTALL_DIR, BASE_FILE)}, put overrides under ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, or link a stylesheet from the frame.`
|
|
31
31
|
|
|
32
|
+
export type TokenKind =
|
|
33
|
+
| 'color'
|
|
34
|
+
| 'spacing'
|
|
35
|
+
| 'radius'
|
|
36
|
+
| 'font-family'
|
|
37
|
+
| 'font-size'
|
|
38
|
+
| 'other'
|
|
39
|
+
|
|
40
|
+
export interface Token {
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly value: string
|
|
43
|
+
/** The literal a `var()` value ends at, where the sheet defines it. */
|
|
44
|
+
readonly resolved?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TokenGroup {
|
|
48
|
+
readonly kind: TokenKind
|
|
49
|
+
readonly tokens: readonly Token[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const KIND_ORDER: readonly TokenKind[] = [
|
|
53
|
+
'color',
|
|
54
|
+
'spacing',
|
|
55
|
+
'radius',
|
|
56
|
+
'font-family',
|
|
57
|
+
'font-size',
|
|
58
|
+
'other',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** By the naming the toolkit's module and most token sheets share. */
|
|
62
|
+
function kindOf(name: string): TokenKind {
|
|
63
|
+
if (name.startsWith('--color-')) return 'color'
|
|
64
|
+
if (/^--(space|spacing)-/.test(name)) return 'spacing'
|
|
65
|
+
if (name.startsWith('--radius-')) return 'radius'
|
|
66
|
+
if (/-family$|^--font-family-/.test(name)) return 'font-family'
|
|
67
|
+
if (/-size$|^--font-size-|^--text-/.test(name)) return 'font-size'
|
|
68
|
+
return 'other'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:\s*([^;}]*)/g
|
|
72
|
+
|
|
73
|
+
const REFERENCE = /^var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)$/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The custom properties a token sheet defines, grouped by kind. A property
|
|
77
|
+
* defined twice keeps its first value, which is the dark theme's in this
|
|
78
|
+
* toolkit's module and the base's in an installed sheet. Empty groups drop.
|
|
79
|
+
*/
|
|
80
|
+
export function tokenGroups(css: string): TokenGroup[] {
|
|
81
|
+
const values = new Map<string, string>()
|
|
82
|
+
for (const [, name, value] of css
|
|
83
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
84
|
+
.matchAll(DECLARATION)) {
|
|
85
|
+
if (name && value !== undefined && !values.has(name)) {
|
|
86
|
+
values.set(name, value.trim())
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolve = (value: string, seen: Set<string>): string | undefined => {
|
|
91
|
+
const reference = value.match(REFERENCE)?.[1]
|
|
92
|
+
if (!reference) return value
|
|
93
|
+
const next = values.get(reference)
|
|
94
|
+
if (next === undefined || seen.has(reference)) return undefined
|
|
95
|
+
return resolve(next, new Set([...seen, reference]))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const grouped = new Map<TokenKind, Token[]>()
|
|
99
|
+
for (const [name, value] of values) {
|
|
100
|
+
const resolved = REFERENCE.test(value)
|
|
101
|
+
? resolve(value, new Set([name]))
|
|
102
|
+
: undefined
|
|
103
|
+
const token =
|
|
104
|
+
resolved === undefined ? { name, value } : { name, value, resolved }
|
|
105
|
+
const kind = kindOf(name)
|
|
106
|
+
grouped.set(kind, [...(grouped.get(kind) ?? []), token])
|
|
107
|
+
}
|
|
108
|
+
return KIND_ORDER.flatMap((kind) => {
|
|
109
|
+
const tokens = grouped.get(kind)
|
|
110
|
+
return tokens ? [{ kind, tokens }] : []
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
32
114
|
/** The installed files in cascade order: the base first, then each override. */
|
|
33
115
|
function installedFiles(root: string): string[] {
|
|
34
116
|
const files: string[] = []
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -104,6 +104,10 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
|
|
107
|
-
expect: 'deploy-
|
|
107
|
+
expect: 'deploy-app',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
prompt: 'Set up a Vercel project for this app and wire up its secrets.',
|
|
111
|
+
expect: 'deploy-app',
|
|
108
112
|
},
|
|
109
113
|
]
|
package/src/commands/canvas.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
renamePage,
|
|
15
15
|
} from '@/canvas/content'
|
|
16
16
|
import { captureCanvas } from '@/canvas/capture'
|
|
17
|
+
import {
|
|
18
|
+
type EditRefused,
|
|
19
|
+
editFrameAtIndex,
|
|
20
|
+
STYLE_PROPERTIES,
|
|
21
|
+
} from '@/canvas/edit'
|
|
17
22
|
import { CANVAS_PORT, startCanvas } from '@/canvas/server'
|
|
18
23
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
19
24
|
import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
|
|
@@ -40,6 +45,7 @@ interface Refused {
|
|
|
40
45
|
readonly ok: false
|
|
41
46
|
readonly reason:
|
|
42
47
|
| ContentRefused['reason']
|
|
48
|
+
| EditRefused['reason']
|
|
43
49
|
| 'no-root'
|
|
44
50
|
| 'no-server'
|
|
45
51
|
| 'capture-failed'
|
|
@@ -102,7 +108,7 @@ export function register(program: Command): void {
|
|
|
102
108
|
.command('canvas')
|
|
103
109
|
.helpOption('-h, --help', 'Show this help message')
|
|
104
110
|
.description(
|
|
105
|
-
'Local canvas of pages and HTML frames (serve, list, page, frame, selection, capture)',
|
|
111
|
+
'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, capture)',
|
|
106
112
|
)
|
|
107
113
|
.addHelpText(
|
|
108
114
|
'after',
|
|
@@ -118,6 +124,7 @@ export function register(program: Command): void {
|
|
|
118
124
|
' canon canvas frame add drafts hero --width 1440 --height 900',
|
|
119
125
|
' canon canvas frame move drafts hero --x 200 --y 120',
|
|
120
126
|
' canon canvas selection --json',
|
|
127
|
+
" canon canvas edit drafts/hero --element 4 --set 'color=var(--color-accent)'",
|
|
121
128
|
' canon canvas capture drafts/hero',
|
|
122
129
|
' canon canvas list --json',
|
|
123
130
|
'',
|
|
@@ -366,6 +373,93 @@ export function register(program: Command): void {
|
|
|
366
373
|
outro()
|
|
367
374
|
})
|
|
368
375
|
|
|
376
|
+
withRoot(
|
|
377
|
+
canvas
|
|
378
|
+
.command('edit')
|
|
379
|
+
.description(
|
|
380
|
+
'Set one property of an element in a frame, the writer the inspector uses',
|
|
381
|
+
)
|
|
382
|
+
.argument('<target>', '<page>/<frame>')
|
|
383
|
+
.requiredOption(
|
|
384
|
+
'--element <index>',
|
|
385
|
+
'The element by its index in document order, as canvas selection reports it',
|
|
386
|
+
)
|
|
387
|
+
.requiredOption(
|
|
388
|
+
'--set <property=value>',
|
|
389
|
+
'The property and its value, where an empty value drops a style property',
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
.addHelpText(
|
|
393
|
+
'after',
|
|
394
|
+
[
|
|
395
|
+
'',
|
|
396
|
+
`Properties: text, ${STYLE_PROPERTIES.join(', ')}.`,
|
|
397
|
+
'A style property is written into the element inline style and',
|
|
398
|
+
'leaves its other properties as they were. text replaces the text of',
|
|
399
|
+
'an element holding text alone, and refuses one holding elements.',
|
|
400
|
+
'Every byte outside the element stays as it was.',
|
|
401
|
+
'',
|
|
402
|
+
].join('\n'),
|
|
403
|
+
)
|
|
404
|
+
.action(
|
|
405
|
+
async (
|
|
406
|
+
target: string,
|
|
407
|
+
opts: RootOptions & { element: string; set: string },
|
|
408
|
+
) => {
|
|
409
|
+
const emitJson = opts.json ?? false
|
|
410
|
+
const resolved = await resolveRoot(opts)
|
|
411
|
+
if (!resolved.ok) {
|
|
412
|
+
process.exitCode = refuse(resolved, emitJson)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
const { root } = resolved
|
|
416
|
+
const [pageName, frameName, ...rest] = target.split('/')
|
|
417
|
+
if (!pageName || !frameName || rest.length > 0) {
|
|
418
|
+
process.exitCode = refuse(
|
|
419
|
+
{
|
|
420
|
+
ok: false,
|
|
421
|
+
reason: 'invalid-name',
|
|
422
|
+
detail: `${target} is not <page>/<frame>`,
|
|
423
|
+
},
|
|
424
|
+
emitJson,
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const split = opts.set.indexOf('=')
|
|
429
|
+
if (split <= 0) {
|
|
430
|
+
process.exitCode = refuse(
|
|
431
|
+
{
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: 'invalid-edit',
|
|
434
|
+
detail: `${opts.set} is not <property>=<value>`,
|
|
435
|
+
},
|
|
436
|
+
emitJson,
|
|
437
|
+
)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
const property = opts.set.slice(0, split).trim()
|
|
441
|
+
const value = opts.set.slice(split + 1)
|
|
442
|
+
const index =
|
|
443
|
+
opts.element.trim() === '' ? Number.NaN : Number(opts.element)
|
|
444
|
+
const outcome = editFrameAtIndex(root, pageName, frameName, index, {
|
|
445
|
+
property,
|
|
446
|
+
value,
|
|
447
|
+
})
|
|
448
|
+
if (!outcome.ok) {
|
|
449
|
+
process.exitCode = refuse(outcome, emitJson)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
const path = resolve(canvasDir(root), outcome.page, outcome.file)
|
|
453
|
+
if (emitJson) writeJson({ ...outcome, property, value, path })
|
|
454
|
+
intro(BANNER)
|
|
455
|
+
logAdd(`${outcome.page}/${outcome.file}`)
|
|
456
|
+
logInfo(
|
|
457
|
+
`element ${index}: ${property} ${value === '' ? 'dropped' : `= ${value}`}`,
|
|
458
|
+
)
|
|
459
|
+
outro()
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
|
|
369
463
|
withRoot(
|
|
370
464
|
canvas
|
|
371
465
|
.command('capture')
|