@erclx/canon 5.17.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 (37) 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/docs/agents/architecture-record.md +12 -4
  7. package/docs/agents/canvas.md +13 -1
  8. package/docs/agents/commands.md +2 -2
  9. package/docs/agents/context-audit-checks.md +1 -1
  10. package/docs/agents/context-audit.md +4 -4
  11. package/docs/agents/index.md +2 -2
  12. package/docs/workflow/skill-map.md +9 -9
  13. package/package.json +1 -1
  14. package/src/audits/catalog.ts +46 -1
  15. package/src/canvas/address.ts +73 -39
  16. package/src/canvas/client/inspector.tsx +241 -33
  17. package/src/canvas/client/pages-panel.tsx +51 -0
  18. package/src/canvas/client/shell.css +126 -1
  19. package/src/canvas/client/state.ts +120 -4
  20. package/src/canvas/client/theme-panel.tsx +55 -0
  21. package/src/canvas/content.ts +14 -3
  22. package/src/canvas/edit.ts +360 -0
  23. package/src/canvas/server.ts +50 -5
  24. package/src/canvas/tokens.ts +82 -0
  25. package/src/claude/cases/misc.ts +5 -1
  26. package/src/commands/canvas.ts +95 -1
  27. package/src/commands/context.ts +85 -5
  28. package/src/context/architecture.ts +98 -18
  29. package/src/context/gate.ts +30 -3
  30. package/src/context/requirements.ts +77 -0
  31. package/src/gate/measures.ts +69 -28
  32. package/src/gate/stages.ts +5 -5
  33. package/standards/architecture.md +13 -7
  34. package/standards/requirements.md +4 -0
  35. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  36. package/tooling/cloudflare/reference.md +1 -1
  37. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -1,6 +1,7 @@
1
1
  import { computed, signal } from '@preact/signals'
2
2
  import type { ElementAddress } from '@/canvas/address'
3
3
  import type { Frame, Page } from '@/canvas/content'
4
+ import type { TokenGroup } from '@/canvas/tokens'
4
5
 
5
6
  /**
6
7
  * Everything the panels read. Type imports only from outside `client/`, since
@@ -15,6 +16,17 @@ export interface TokenSource {
15
16
  readonly source: 'toolkit' | 'installed' | 'none'
16
17
  readonly files?: readonly string[]
17
18
  readonly notice?: string
19
+ /** What the stylesheet defines, which the token picker and Theme tab list. */
20
+ readonly groups?: readonly TokenGroup[]
21
+ }
22
+
23
+ export type LeftTab = 'pages' | 'theme'
24
+
25
+ /** One property change to the selected element, as the inspector posts it. */
26
+ export interface PendingEdit {
27
+ readonly key: string
28
+ readonly index: number
29
+ readonly property: string
18
30
  }
19
31
 
20
32
  export interface FrameRef {
@@ -98,6 +110,26 @@ export const draggingFrame = signal<string | undefined>(undefined)
98
110
  /** Why the last write to the canvas failed, until the next one succeeds. */
99
111
  export const writeError = signal<string | undefined>(undefined)
100
112
 
113
+ export const leftTab = signal<LeftTab>('pages')
114
+
115
+ /** The edit in flight, which holds its field until the server answers. */
116
+ export const pendingEdit = signal<PendingEdit | undefined>(undefined)
117
+
118
+ /** The edit that last landed, for a moment after it does. */
119
+ export const savedEdit = signal<PendingEdit | undefined>(undefined)
120
+
121
+ const SAVED_MS = 2500
122
+
123
+ /** Why the last edit wrote nothing, until the next one lands. */
124
+ export const editRefusal = signal<string | undefined>(undefined)
125
+
126
+ /**
127
+ * The hash an edit answered by frame key, until that frame reloads. A second
128
+ * edit sent before the reload is made from the file the first one wrote, not
129
+ * from the document still on screen.
130
+ */
131
+ export const editedHashes = signal<ReadonlyMap<string, string>>(new Map())
132
+
101
133
  export const currentPage = computed<Page | undefined>(() => {
102
134
  const all = pages.value
103
135
  return all.find((page) => page.name === selectedPage.value) ?? all[0]
@@ -142,6 +174,17 @@ export function registerFrameDocument(key: string, doc: Document): void {
142
174
  const next = new Map(frameDocuments.value)
143
175
  next.set(key, doc)
144
176
  frameDocuments.value = next
177
+ if (editedHashes.value.has(key)) {
178
+ const hashes = new Map(editedHashes.value)
179
+ hashes.delete(key)
180
+ editedHashes.value = hashes
181
+ }
182
+ }
183
+
184
+ function reloadFrame(key: string): void {
185
+ const next = new Map(frameVersions.value)
186
+ next.set(key, (next.get(key) ?? 0) + 1)
187
+ frameVersions.value = next
145
188
  }
146
189
 
147
190
  export function toggleLayers(key: string): void {
@@ -181,10 +224,7 @@ export async function applyChange(
181
224
  fetchImpl: typeof fetch = fetch,
182
225
  ): Promise<void> {
183
226
  if (change.file.endsWith('.html')) {
184
- const key = `${change.page}/${change.file}`
185
- const next = new Map(frameVersions.value)
186
- next.set(key, (next.get(key) ?? 0) + 1)
187
- frameVersions.value = next
227
+ reloadFrame(`${change.page}/${change.file}`)
188
228
  }
189
229
  await loadPages(fetchImpl)
190
230
  }
@@ -267,6 +307,77 @@ export async function selectElement(
267
307
  await write('/api/selection', { ...ref, element: address }, fetchImpl)
268
308
  }
269
309
 
310
+ /** What a refused edit tells the operator, by the server's reason. */
311
+ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
312
+ 'stale-address':
313
+ 'The frame changed before this edit arrived, so nothing was saved. It reloads with the file as it stands now. Make the edit again there.',
314
+ 'address-mismatch':
315
+ 'Could not save, since the browser and the file count the elements of this frame differently.',
316
+ 'not-text-only':
317
+ 'Could not save the text, since this element holds other elements.',
318
+ }
319
+
320
+ /**
321
+ * Posts one property change to the element the address names. A refusal
322
+ * says why and reloads the frame, so a pending value never sits over a file
323
+ * that moved under it.
324
+ */
325
+ export async function editElement(
326
+ ref: FrameRef,
327
+ key: string,
328
+ address: ElementAddress,
329
+ property: string,
330
+ value: string,
331
+ fetchImpl: typeof fetch = fetch,
332
+ ): Promise<void> {
333
+ const hash = editedHashes.value.get(key) ?? address.hash
334
+ pendingEdit.value = { key, index: address.index, property }
335
+ try {
336
+ const response = await fetchImpl('/api/frames/edit', {
337
+ method: 'POST',
338
+ headers: { 'content-type': 'application/json' },
339
+ body: JSON.stringify({
340
+ ...ref,
341
+ element: { ...address, hash },
342
+ property,
343
+ value,
344
+ }),
345
+ })
346
+ const body: unknown = await response.json().catch(() => undefined)
347
+ const record =
348
+ typeof body === 'object' && body !== null
349
+ ? (body as Record<string, unknown>)
350
+ : {}
351
+ if (!response.ok) {
352
+ const reason = typeof record.reason === 'string' ? record.reason : ''
353
+ const detail = typeof record.detail === 'string' ? record.detail : ''
354
+ editRefusal.value =
355
+ EDIT_NOTICES[reason] ??
356
+ (reason === 'invalid-edit'
357
+ ? `Could not save that value (${detail}).`
358
+ : `Could not save (status ${response.status}). Check canon canvas serve is still running.`)
359
+ reloadFrame(key)
360
+ await loadPages(fetchImpl)
361
+ return
362
+ }
363
+ editRefusal.value = undefined
364
+ const saved = pendingEdit.value
365
+ savedEdit.value = saved
366
+ setTimeout(() => {
367
+ if (savedEdit.value === saved) savedEdit.value = undefined
368
+ }, SAVED_MS)
369
+ if (typeof record.hash === 'string') {
370
+ const hashes = new Map(editedHashes.value)
371
+ hashes.set(key, record.hash)
372
+ editedHashes.value = hashes
373
+ }
374
+ } catch (error) {
375
+ editRefusal.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
376
+ } finally {
377
+ pendingEdit.value = undefined
378
+ }
379
+ }
380
+
270
381
  /**
271
382
  * Moves a frame on the surface without writing, which a drag calls on every
272
383
  * pointer move. The next reread replaces it with what the layout holds.
@@ -369,4 +480,9 @@ export function resetState(): void {
369
480
  expandedFrames.value = new Set()
370
481
  draggingFrame.value = undefined
371
482
  writeError.value = undefined
483
+ leftTab.value = 'pages'
484
+ pendingEdit.value = undefined
485
+ savedEdit.value = undefined
486
+ editRefusal.value = undefined
487
+ editedHashes.value = new Map()
372
488
  }
@@ -0,0 +1,55 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { tokens } from '@/canvas/client/state'
4
+ import type { Token, TokenKind } from '@/canvas/tokens'
5
+
6
+ const KIND_LABEL: Record<TokenKind, string> = {
7
+ color: 'Color',
8
+ spacing: 'Spacing',
9
+ radius: 'Radius',
10
+ 'font-family': 'Font family',
11
+ 'font-size': 'Font size',
12
+ other: 'Other',
13
+ }
14
+
15
+ /**
16
+ * A swatch paints the value in the chrome's document, where the frame's own
17
+ * custom properties are not defined, so a value still naming one shows none.
18
+ */
19
+ function Swatch({ token }: { readonly token: Token }): JSX.Element | null {
20
+ const paint = token.resolved ?? token.value
21
+ if (paint.includes('var(')) return null
22
+ return (
23
+ <span class="swatch" aria-hidden="true" style={{ background: paint }} />
24
+ )
25
+ }
26
+
27
+ /** Read-only: the frames' tokens are edited in their stylesheet, not here. */
28
+ export function ThemePanel(): JSX.Element {
29
+ const groups = tokens.value?.groups ?? []
30
+ return (
31
+ <section aria-label="Theme">
32
+ {groups.length === 0 ? (
33
+ <p class="empty">
34
+ No tokens resolve for this project, so there is nothing to list.{' '}
35
+ {tokens.value?.notice ?? ''}
36
+ </p>
37
+ ) : (
38
+ groups.map((group) => (
39
+ <div key={group.kind}>
40
+ <h2 class="section-label">{KIND_LABEL[group.kind]}</h2>
41
+ <ul class="tokens" aria-label={`${KIND_LABEL[group.kind]} tokens`}>
42
+ {group.tokens.map((token) => (
43
+ <li key={token.name} class="token" title={token.value}>
44
+ {group.kind === 'color' ? <Swatch token={token} /> : null}
45
+ <span class="row-label">{token.name}</span>
46
+ <span class="row-meta token-value">{token.value}</span>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ </div>
51
+ ))
52
+ )}
53
+ </section>
54
+ )
55
+ }
@@ -12,7 +12,7 @@ import {
12
12
  unlinkSync,
13
13
  writeFileSync,
14
14
  } from 'node:fs'
15
- import { join } from 'node:path'
15
+ import { basename, join } from 'node:path'
16
16
  import {
17
17
  type ElementAddress,
18
18
  resolveAddress,
@@ -200,7 +200,18 @@ function sleep(ms: number): void {
200
200
  * found it.
201
201
  */
202
202
  function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
203
- const lock = join(dir, `${LAYOUT_FILE}${LOCK_SUFFIX}`)
203
+ return withFileLock(join(dir, LAYOUT_FILE), run)
204
+ }
205
+
206
+ /**
207
+ * The same lock over any one file under the canvas folder, which a frame
208
+ * writer takes so its hash check and its write cannot straddle another's.
209
+ */
210
+ export function withFileLock<T>(
211
+ target: string,
212
+ run: () => T,
213
+ ): T | ContentRefused {
214
+ const lock = `${target}${LOCK_SUFFIX}`
204
215
  const deadline = Date.now() + LOCK_WAIT_MS
205
216
  for (;;) {
206
217
  try {
@@ -214,7 +225,7 @@ function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
214
225
  continue
215
226
  }
216
227
  if (Date.now() >= deadline) {
217
- return refuse('busy', `${LAYOUT_FILE} is being written, try again`)
228
+ return refuse('busy', `${basename(target)} is being written, try again`)
218
229
  }
219
230
  sleep(LOCK_POLL_MS)
220
231
  }
@@ -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
+ }