@erclx/canon 5.28.0 → 5.30.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 (81) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/review-ui/references/post.md +7 -5
  3. package/docs/agents/canvas.md +8 -0
  4. package/docs/agents/pr-evidence.md +7 -7
  5. package/package.json +1 -1
  6. package/src/canvas/client/app.tsx +105 -4
  7. package/src/canvas/client/inspector.tsx +13 -4
  8. package/src/canvas/client/keys.ts +23 -6
  9. package/src/canvas/client/main.tsx +9 -0
  10. package/src/canvas/client/pages-panel.tsx +5 -1
  11. package/src/canvas/client/panel-handle.tsx +90 -0
  12. package/src/canvas/client/shell.css +86 -2
  13. package/src/canvas/client/state.ts +159 -6
  14. package/src/canvas/client/surface.tsx +37 -1
  15. package/src/canvas/client/tools.tsx +91 -2
  16. package/src/canvas/content.ts +65 -0
  17. package/src/canvas/edit.ts +195 -23
  18. package/src/canvas/history.ts +116 -0
  19. package/src/canvas/server.ts +126 -13
  20. package/src/{exec.ts → cli/exec.ts} +1 -1
  21. package/src/{help.ts → cli/help.ts} +1 -1
  22. package/src/cli.ts +1 -1
  23. package/src/commands/audits.ts +9 -1
  24. package/src/commands/autoship.ts +1 -1
  25. package/src/commands/canvas.ts +1 -1
  26. package/src/commands/capture.ts +1 -1
  27. package/src/commands/census.ts +1 -1
  28. package/src/commands/claude.ts +2 -2
  29. package/src/commands/comments.ts +1 -1
  30. package/src/commands/context.ts +1 -1
  31. package/src/commands/demo.ts +9 -1
  32. package/src/commands/deps.ts +1 -1
  33. package/src/commands/design.ts +9 -1
  34. package/src/commands/docs.ts +1 -1
  35. package/src/commands/driver.ts +9 -1
  36. package/src/commands/feedback.ts +1 -1
  37. package/src/commands/gate.ts +1 -1
  38. package/src/commands/gov.ts +1 -1
  39. package/src/commands/indexes.ts +1 -1
  40. package/src/commands/init.ts +10 -2
  41. package/src/commands/intake.ts +1 -1
  42. package/src/commands/inventory.ts +9 -1
  43. package/src/commands/labels.ts +1 -1
  44. package/src/commands/markdown.ts +1 -1
  45. package/src/commands/migrate.ts +1 -1
  46. package/src/commands/pr.ts +141 -2
  47. package/src/commands/records.ts +1 -1
  48. package/src/commands/repo.ts +1 -1
  49. package/src/commands/sandbox.ts +1 -1
  50. package/src/commands/secrets.ts +9 -1
  51. package/src/commands/sessions.ts +1 -1
  52. package/src/commands/slides.ts +1 -1
  53. package/src/commands/standards.ts +1 -1
  54. package/src/commands/sync.ts +2 -2
  55. package/src/commands/targets.ts +1 -1
  56. package/src/commands/tasks.ts +1 -1
  57. package/src/commands/teach.ts +1 -1
  58. package/src/commands/tooling.ts +1 -1
  59. package/src/commands/transcripts.ts +1 -1
  60. package/src/commands/upgrade.ts +1 -1
  61. package/src/commands/wiki.ts +1 -1
  62. package/src/commands/worktrees.ts +1 -1
  63. package/src/design/brand-favicon.ts +18 -0
  64. package/src/gate/measures.ts +1 -1
  65. package/src/init/run.ts +1 -1
  66. package/src/migrate/rename.ts +1 -1
  67. package/src/pr/evidence.ts +41 -7
  68. package/src/roots/project.ts +1 -1
  69. package/src/sandbox/headless/run.ts +1 -1
  70. package/src/sandbox/provision.ts +1 -1
  71. package/src/sandbox/scenario.ts +1 -1
  72. package/src/serve/report.ts +1 -1
  73. package/src/sync/engine.ts +1 -1
  74. package/src/sync/git.ts +1 -1
  75. package/src/sync/workflow.ts +1 -1
  76. package/src/targets/validate.ts +1 -1
  77. package/src/teach/nav.ts +5 -12
  78. package/src/tooling/inject.ts +1 -1
  79. package/src/tooling/verify-stack.ts +1 -1
  80. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  81. /package/src/{ui.ts → cli/ui.ts} +0 -0
@@ -17,6 +17,7 @@ import {
17
17
  withFileLock,
18
18
  writeSelection,
19
19
  } from '@/canvas/content'
20
+ import type { ElementState } from '@/canvas/history'
20
21
 
21
22
  /**
22
23
  * The basic set an operator edits by hand. Each is a CSS property written into
@@ -103,6 +104,11 @@ const DECLARATION_BREAK = /[;{}<>\n\r]/
103
104
 
104
105
  const BOM = ''
105
106
 
107
+ /* The rewriter drops a leading byte order mark, so a read skips it first. */
108
+ function withoutBom(html: string): string {
109
+ return html.startsWith(BOM) ? html.slice(BOM.length) : html
110
+ }
111
+
106
112
  function refuse(reason: EditRefusal, detail: string): EditRefused {
107
113
  return { ok: false, reason, detail }
108
114
  }
@@ -292,11 +298,106 @@ export function applyEdit(
292
298
  return { ok: true, html: `${bom}${rewritten}` }
293
299
  }
294
300
 
301
+ interface ElementRead {
302
+ readonly tag: string
303
+ readonly style: string | undefined
304
+ /** The raw text inside it, entities still encoded, when it holds text alone. */
305
+ readonly inner: string | undefined
306
+ }
307
+
308
+ /**
309
+ * What the element at an index holds, read as the file states it. Each text
310
+ * chunk the rewriter hands over is the source text, so the inner content keeps
311
+ * every entity as written.
312
+ */
313
+ function readElement(html: string, index: number): ElementRead | undefined {
314
+ let found: { tag: string; style: string | undefined } | undefined
315
+ let isOpen = false
316
+ let hasChild = false
317
+ let inner = ''
318
+ countElements(new HTMLRewriter(), (element, at) => {
319
+ if (isOpen) hasChild = true
320
+ if (at !== index) return undefined
321
+ found = {
322
+ tag: element.tagName.toLowerCase(),
323
+ style: element.getAttribute('style') ?? undefined,
324
+ }
325
+ if (!element.canHaveContent || element.selfClosing) return undefined
326
+ isOpen = true
327
+ return () => {
328
+ isOpen = false
329
+ }
330
+ })
331
+ .onDocument({
332
+ text(chunk) {
333
+ if (isOpen) inner += chunk.text
334
+ },
335
+ })
336
+ .transform(html)
337
+ if (!found) return undefined
338
+ return { ...found, inner: hasChild ? undefined : inner }
339
+ }
340
+
341
+ function stateOf(read: ElementRead, withInner: boolean): ElementState {
342
+ return withInner
343
+ ? { style: read.style, inner: read.inner }
344
+ : { style: read.style }
345
+ }
346
+
347
+ function sameState(read: ElementRead, state: ElementState): boolean {
348
+ if (read.style !== state.style) return false
349
+ return state.inner === undefined || read.inner === state.inner
350
+ }
351
+
352
+ /**
353
+ * Restamps a selection that was fresh before a write, since neither an edit
354
+ * nor a restore moves an element.
355
+ */
356
+ function restampSelection(
357
+ root: string,
358
+ page: string,
359
+ frame: string,
360
+ before: ReturnType<typeof readSelection>,
361
+ html: string,
362
+ ): void {
363
+ if (
364
+ before?.page !== page ||
365
+ before.frame !== frame ||
366
+ !before.element ||
367
+ before.element.stale
368
+ ) {
369
+ return
370
+ }
371
+ writeSelection(root, {
372
+ page,
373
+ frame,
374
+ element: {
375
+ index: before.element.index,
376
+ tag: before.element.tag,
377
+ count: sourceElements(html).length,
378
+ },
379
+ })
380
+ }
381
+
382
+ function writeFrame(path: string, html: string): void {
383
+ const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
384
+ writeFileSync(temp, html)
385
+ renameSync(temp, path)
386
+ }
387
+
388
+ /** The element's state on each side of an edit, which undo restores. */
389
+ export type EditObserver = (states: {
390
+ readonly before: ElementState
391
+ readonly after: ElementState
392
+ }) => void
393
+
295
394
  /**
296
395
  * Edits one element of a frame file under the file's lock. An address carrying
297
396
  * a hash the file no longer has is refused rather than applied at an index the
298
397
  * change may have shifted. A selection that was fresh before the edit is
299
- * restamped after it, since the edit moves no element.
398
+ * restamped after it, since the edit moves no element. An observer hears the
399
+ * element's state before and after a write that landed, apart from the
400
+ * returned record, which the CLI prints.
300
401
  */
301
402
  export function editFrame(
302
403
  root: string,
@@ -304,6 +405,7 @@ export function editFrame(
304
405
  frame: string,
305
406
  address: ElementAddress,
306
407
  change: EditChange,
408
+ observe?: EditObserver,
307
409
  ): FrameEdit {
308
410
  const invalid = checkChange(change)
309
411
  if (invalid) return invalid
@@ -324,30 +426,25 @@ export function editFrame(
324
426
  `${page}/${frame} changed after this edit was made, so nothing was written`,
325
427
  )
326
428
  }
327
- const before = readSelection(root)
328
- const isSelectionFresh =
329
- before?.page === page &&
330
- before.frame === frame &&
331
- before.element !== undefined &&
332
- !before.element.stale
333
-
334
- const applied = applyEdit(bytes.toString('utf8'), address, change)
429
+ const selected = readSelection(root)
430
+ const source = bytes.toString('utf8')
431
+
432
+ const applied = applyEdit(source, address, change)
335
433
  if (!applied.ok) return applied
336
434
 
337
- const temp = `${path}.${process.pid}${TEMP_SUFFIX}`
338
- writeFileSync(temp, applied.html)
339
- renameSync(temp, path)
340
-
341
- if (isSelectionFresh && before.element) {
342
- writeSelection(root, {
343
- page,
344
- frame,
345
- element: {
346
- index: before.element.index,
347
- tag: before.element.tag,
348
- count: sourceElements(applied.html).length,
349
- },
350
- })
435
+ writeFrame(path, applied.html)
436
+ restampSelection(root, page, frame, selected, applied.html)
437
+
438
+ if (observe) {
439
+ const withInner = change.property === 'text'
440
+ const before = readElement(withoutBom(source), address.index)
441
+ const after = readElement(withoutBom(applied.html), address.index)
442
+ if (before && after) {
443
+ observe({
444
+ before: stateOf(before, withInner),
445
+ after: stateOf(after, withInner),
446
+ })
447
+ }
351
448
  }
352
449
  return {
353
450
  ok: true,
@@ -359,6 +456,81 @@ export function editFrame(
359
456
  })
360
457
  }
361
458
 
459
+ export type RestoreRefusal = EditRefusal | 'changed'
460
+
461
+ export type ElementRestore =
462
+ | { readonly ok: true; readonly hash: string }
463
+ | {
464
+ readonly ok: false
465
+ readonly reason: RestoreRefusal
466
+ readonly detail: string
467
+ }
468
+
469
+ /**
470
+ * Writes a recorded state back to one element under the file's lock, only
471
+ * while the file holds as many elements as it did at the edit and the element
472
+ * at that index still carries the tag and the state the record expects. An
473
+ * unstyled state matches any unstyled sibling, so the count is what catches
474
+ * an element inserted or removed ahead of the target. Anything else means the
475
+ * file moved on since, so the restore refuses as `changed` rather than
476
+ * overwrite a newer state or land on a neighbor. The state comes from the
477
+ * server's own record, never a request, since inner content is written back
478
+ * as markup.
479
+ */
480
+ export function restoreElement(
481
+ root: string,
482
+ page: string,
483
+ frame: string,
484
+ target: {
485
+ readonly index: number
486
+ readonly tag: string
487
+ readonly count: number
488
+ },
489
+ expected: ElementState,
490
+ to: ElementState,
491
+ ): ElementRestore {
492
+ const onPage = readPage(root, page)
493
+ if (!onPage) return refuse('no-page', `page ${page} does not exist`)
494
+ const found = onPage.frames.find((candidate) => candidate.name === frame)
495
+ if (!found) {
496
+ return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
497
+ }
498
+
499
+ const path = join(canvasDir(root), page, found.file)
500
+ return withFileLock(path, (): ElementRestore => {
501
+ const source = readFileSync(path, 'utf8')
502
+ const bom = source.startsWith(BOM) ? BOM : ''
503
+ const html = source.slice(bom.length)
504
+ const current = readElement(html, target.index)
505
+ if (
506
+ sourceElements(html).length !== target.count ||
507
+ current?.tag !== target.tag ||
508
+ !sameState(current, expected)
509
+ ) {
510
+ return {
511
+ ok: false,
512
+ reason: 'changed',
513
+ detail: `<${target.tag}> in ${page}/${frame} changed since this edit, so nothing was written`,
514
+ }
515
+ }
516
+ const selected = readSelection(root)
517
+ const rewritten = countElements(new HTMLRewriter(), (element, at) => {
518
+ if (at !== target.index) return undefined
519
+ if (to.style === undefined) element.removeAttribute('style')
520
+ else element.setAttribute('style', to.style)
521
+ if (to.inner !== undefined) {
522
+ element.setInnerContent(to.inner, { html: true })
523
+ }
524
+ return undefined
525
+ }).transform(html)
526
+ const written = `${bom}${rewritten}`
527
+
528
+ writeFrame(path, written)
529
+ restampSelection(root, page, frame, selected, written)
530
+ return { ok: true, hash: contentHash(written) }
531
+ })
532
+ }
533
+
362
534
  /**
363
535
  * Edits the element at an index of the file as it stands, for a caller that
364
536
  * holds no served frame to take an address from. The address it builds
@@ -0,0 +1,116 @@
1
+ import type { Box } from '@/canvas/content'
2
+
3
+ /**
4
+ * The operator's shell edits, held by one `canon canvas serve` process. Only
5
+ * the server's write routes record here, so an edit made through a file or a
6
+ * CLI verb never enters it. An entry carries the state it replaced and the
7
+ * state it wrote, and applying it checks the second is still there.
8
+ */
9
+
10
+ /** What an element entry restores: the raw style attribute, and raw inner content for a text edit. */
11
+ export interface ElementState {
12
+ readonly style: string | undefined
13
+ readonly inner?: string
14
+ }
15
+
16
+ export interface ElementEntry {
17
+ readonly kind: 'element'
18
+ readonly page: string
19
+ readonly frame: string
20
+ readonly index: number
21
+ readonly tag: string
22
+ /** How many elements the file held, which an insert or removal ahead of the target changes. */
23
+ readonly count: number
24
+ readonly before: ElementState
25
+ readonly after: ElementState
26
+ /** Edits a control posts as one gesture share this, and merge into one entry. */
27
+ readonly step?: string
28
+ }
29
+
30
+ export interface BoxEntry {
31
+ readonly kind: 'box'
32
+ readonly page: string
33
+ readonly frame: string
34
+ /** Undefined when the layout held no box, so the reader placed the frame. */
35
+ readonly before: Box | undefined
36
+ readonly after: Box
37
+ }
38
+
39
+ export type HistoryEntry = ElementEntry | BoxEntry
40
+
41
+ export type Direction = 'undo' | 'redo'
42
+
43
+ /**
44
+ * `dropped` means the target changed since the entry was recorded, so the
45
+ * entry is discarded unwritten. `busy` leaves it in place to try again.
46
+ */
47
+ export type ApplyResult = 'applied' | 'dropped' | 'busy'
48
+
49
+ export type HistoryOutcome = ApplyResult | 'empty'
50
+
51
+ export interface HistoryFlags {
52
+ readonly canUndo: boolean
53
+ readonly canRedo: boolean
54
+ }
55
+
56
+ export const HISTORY_CAP = 100
57
+
58
+ function isSameTarget(a: ElementEntry, b: ElementEntry): boolean {
59
+ return (
60
+ a.page === b.page &&
61
+ a.frame === b.frame &&
62
+ a.index === b.index &&
63
+ a.tag === b.tag
64
+ )
65
+ }
66
+
67
+ export class History {
68
+ private readonly undone: HistoryEntry[] = []
69
+ private readonly done: HistoryEntry[] = []
70
+
71
+ constructor(private readonly cap = HISTORY_CAP) {}
72
+
73
+ record(entry: HistoryEntry): void {
74
+ this.undone.length = 0
75
+ const last = this.done.at(-1)
76
+ if (
77
+ entry.kind === 'element' &&
78
+ entry.step !== undefined &&
79
+ last?.kind === 'element' &&
80
+ last.step === entry.step &&
81
+ isSameTarget(last, entry)
82
+ ) {
83
+ this.done[this.done.length - 1] = { ...last, after: entry.after }
84
+ return
85
+ }
86
+ this.done.push(entry)
87
+ if (this.done.length > this.cap) this.done.shift()
88
+ }
89
+
90
+ undo(apply: (entry: HistoryEntry, direction: Direction) => ApplyResult) {
91
+ return this.move(this.done, this.undone, 'undo', apply)
92
+ }
93
+
94
+ redo(apply: (entry: HistoryEntry, direction: Direction) => ApplyResult) {
95
+ return this.move(this.undone, this.done, 'redo', apply)
96
+ }
97
+
98
+ flags(): HistoryFlags {
99
+ return { canUndo: this.done.length > 0, canRedo: this.undone.length > 0 }
100
+ }
101
+
102
+ private move(
103
+ from: HistoryEntry[],
104
+ to: HistoryEntry[],
105
+ direction: Direction,
106
+ apply: (entry: HistoryEntry, direction: Direction) => ApplyResult,
107
+ ): HistoryOutcome {
108
+ const entry = from.at(-1)
109
+ if (!entry) return 'empty'
110
+ const result = apply(entry, direction)
111
+ if (result === 'busy') return result
112
+ from.pop()
113
+ if (result === 'applied') to.push(entry)
114
+ return result
115
+ }
116
+ }
@@ -7,6 +7,7 @@ import {
7
7
  TOKENS_ATTRIBUTE,
8
8
  } from '@/canvas/address'
9
9
  import {
10
+ type BoxObserver,
10
11
  type ContentRefused,
11
12
  canvasDir,
12
13
  contentHash,
@@ -15,17 +16,25 @@ import {
15
16
  moveFrame,
16
17
  readSelection,
17
18
  resizeFrame,
19
+ restoreBox,
18
20
  TEMP_SUFFIX,
19
21
  writeSelection,
20
22
  } from '@/canvas/content'
21
- import { type EditRefused, editFrame } from '@/canvas/edit'
23
+ import { type EditRefused, editFrame, restoreElement } from '@/canvas/edit'
22
24
  import { readEditing } from '@/canvas/editing'
25
+ import {
26
+ type ApplyResult,
27
+ type Direction,
28
+ History,
29
+ type HistoryEntry,
30
+ } from '@/canvas/history'
23
31
  import {
24
32
  resolveFrameTokens,
25
33
  type TokenOptions,
26
34
  tokenGroups,
27
35
  } from '@/canvas/tokens'
28
36
  import { buildDesignCss } from '@/design/css'
37
+ import { brandFavicon } from '@/design/brand-favicon'
29
38
  import {
30
39
  bindFirstFree,
31
40
  PORT_ATTEMPTS,
@@ -311,6 +320,59 @@ function isLoopbackOrigin(origin: string, port: number): boolean {
311
320
  )
312
321
  }
313
322
 
323
+ /**
324
+ * Writes one entry back through the writer that recorded it. A busy lock
325
+ * keeps the entry for another try, and any other refusal means the target
326
+ * moved on or went, so the entry drops.
327
+ */
328
+ function applyEntry(
329
+ root: string,
330
+ entry: HistoryEntry,
331
+ direction: Direction,
332
+ ): ApplyResult {
333
+ const isUndo = direction === 'undo'
334
+ const outcome =
335
+ entry.kind === 'element'
336
+ ? restoreElement(
337
+ root,
338
+ entry.page,
339
+ entry.frame,
340
+ entry,
341
+ isUndo ? entry.after : entry.before,
342
+ isUndo ? entry.before : entry.after,
343
+ )
344
+ : restoreBox(
345
+ root,
346
+ entry.page,
347
+ entry.frame,
348
+ isUndo ? entry.after : entry.before,
349
+ isUndo ? entry.before : entry.after,
350
+ )
351
+ if (outcome.ok) return 'applied'
352
+ return outcome.reason === 'busy' ? 'busy' : 'dropped'
353
+ }
354
+
355
+ function stepHistory(
356
+ root: string,
357
+ history: History,
358
+ direction: Direction,
359
+ ): Response {
360
+ const apply = (entry: HistoryEntry) => applyEntry(root, entry, direction)
361
+ const outcome =
362
+ direction === 'undo' ? history.undo(apply) : history.redo(apply)
363
+ if (outcome === 'busy') {
364
+ return json(
365
+ {
366
+ ok: false,
367
+ reason: 'busy',
368
+ detail: 'the file is being written, try again',
369
+ },
370
+ 409,
371
+ )
372
+ }
373
+ return json({ ok: true, outcome, history: history.flags() })
374
+ }
375
+
314
376
  function isRecord(value: unknown): value is Record<string, unknown> {
315
377
  return typeof value === 'object' && value !== null
316
378
  }
@@ -444,6 +506,16 @@ export function startCanvas(
444
506
  embedFonts: true,
445
507
  })
446
508
  const changes = new ChangeStream(content)
509
+ /*
510
+ * Held here and written only by the shell's write routes, so an edit a CLI
511
+ * verb makes in its own process never enters it.
512
+ */
513
+ const history = new History()
514
+ const recordBox =
515
+ (page: string, frame: string): BoxObserver =>
516
+ (before, after) => {
517
+ history.record({ kind: 'box', page, frame, before, after })
518
+ }
447
519
  const first = options.port ?? CANVAS_PORT
448
520
 
449
521
  let bound: ReturnType<typeof bindFirstFree>
@@ -487,8 +559,15 @@ export function startCanvas(
487
559
  editing: readEditing(root, new Date()).map(
488
560
  ({ page, frame, by, until }) => ({ page, frame, by, until }),
489
561
  ),
562
+ history: history.flags(),
490
563
  })
491
564
  }),
565
+ '/api/history/undo': {
566
+ POST: guarded(mutation(() => stepHistory(root, history, 'undo'))),
567
+ },
568
+ '/api/history/redo': {
569
+ POST: guarded(mutation(() => stepHistory(root, history, 'redo'))),
570
+ },
492
571
  '/api/frames/move': {
493
572
  POST: guarded(
494
573
  mutation((body) => {
@@ -501,10 +580,14 @@ export function startCanvas(
501
580
  ) {
502
581
  return badBody('send page, frame, x, and y')
503
582
  }
504
- const outcome = moveFrame(root, body.page, body.frame, {
505
- x: body.x,
506
- y: body.y,
507
- })
583
+ const { page, frame } = body
584
+ const outcome = moveFrame(
585
+ root,
586
+ page,
587
+ frame,
588
+ { x: body.x, y: body.y },
589
+ recordBox(page, frame),
590
+ )
508
591
  return outcome.ok ? json(outcome) : refusal(outcome)
509
592
  }),
510
593
  ),
@@ -523,12 +606,19 @@ export function startCanvas(
523
606
  ) {
524
607
  return badBody('send page, frame, x, y, width, and height')
525
608
  }
526
- const outcome = resizeFrame(root, body.page, body.frame, {
527
- x: body.x,
528
- y: body.y,
529
- width: body.width,
530
- height: body.height,
531
- })
609
+ const { page, frame } = body
610
+ const outcome = resizeFrame(
611
+ root,
612
+ page,
613
+ frame,
614
+ {
615
+ x: body.x,
616
+ y: body.y,
617
+ width: body.width,
618
+ height: body.height,
619
+ },
620
+ recordBox(page, frame),
621
+ )
532
622
  return outcome.ok ? json(outcome) : refusal(outcome)
533
623
  }),
534
624
  ),
@@ -557,15 +647,32 @@ export function startCanvas(
557
647
  'send an element as index, tag, count, and the hash it was served with',
558
648
  )
559
649
  }
650
+ const { page, frame, step } = body
651
+ if (step !== undefined && typeof step !== 'string') {
652
+ return badBody('send step as a string, or leave it out')
653
+ }
560
654
  const outcome = editFrame(
561
655
  root,
562
- body.page,
563
- body.frame,
656
+ page,
657
+ frame,
564
658
  element,
565
659
  {
566
660
  property: body.property,
567
661
  value: body.value,
568
662
  },
663
+ ({ before, after }) => {
664
+ history.record({
665
+ kind: 'element',
666
+ page,
667
+ frame,
668
+ index: element.index,
669
+ tag: element.tag,
670
+ count: element.count,
671
+ before,
672
+ after,
673
+ ...(step !== undefined && { step }),
674
+ })
675
+ },
569
676
  )
570
677
  return outcome.ok ? json(outcome) : refusal(outcome)
571
678
  }),
@@ -607,6 +714,12 @@ export function startCanvas(
607
714
  },
608
715
  }),
609
716
  ),
717
+ '/api/icon.svg': guarded(
718
+ () =>
719
+ new Response(brandFavicon(), {
720
+ headers: { 'content-type': 'image/svg+xml' },
721
+ }),
722
+ ),
610
723
  '/api/events': guarded(() => changes.open()),
611
724
  },
612
725
  fetch: guarded((request) => {
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { execa } from 'execa'
3
3
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
- import { logWarn } from '@/ui'
4
+ import { logWarn } from '@/cli/ui'
5
5
 
6
6
  export async function execScript(
7
7
  script: string,
@@ -1,4 +1,4 @@
1
- import { palette } from '@/ui'
1
+ import { palette } from '@/cli/ui'
2
2
 
3
3
  const HELP_WIDTH = 80
4
4
  const SIGNATURE_WIDTH = 19
package/src/cli.ts CHANGED
@@ -44,7 +44,7 @@ import { register as census } from '@/commands/census'
44
44
  import { register as targets } from '@/commands/targets'
45
45
  import { register as upgrade } from '@/commands/upgrade'
46
46
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
47
- import { renderHelp } from '@/help'
47
+ import { renderHelp } from '@/cli/help'
48
48
 
49
49
  function showHelp(): void {
50
50
  console.log(renderHelp(process.stdout))
@@ -20,7 +20,15 @@ import {
20
20
  summarize,
21
21
  } from '@/audits/run'
22
22
  import { gitEnv } from '@/git/env'
23
- import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
23
+ import {
24
+ intro,
25
+ logError,
26
+ logInfo,
27
+ logStep,
28
+ logWarn,
29
+ outro,
30
+ plural,
31
+ } from '@/cli/ui'
24
32
  import { currentWorktreeRoot } from '@/git/worktree'
25
33
 
26
34
  interface RunCommandOptions {
@@ -4,7 +4,7 @@ import {
4
4
  type FailedTest,
5
5
  classifyChanges,
6
6
  } from '@/autoship/classify'
7
- import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
7
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
8
8
 
9
9
  interface ClassifyOptions {
10
10
  readonly json?: boolean
@@ -45,7 +45,7 @@ import {
45
45
  logWarn,
46
46
  outro,
47
47
  plural,
48
- } from '@/ui'
48
+ } from '@/cli/ui'
49
49
  import { mainWorktreeRoot } from '@/git/worktree'
50
50
 
51
51
  const BANNER = 'canon canvas'
@@ -15,7 +15,7 @@ import {
15
15
  logWarn,
16
16
  outro,
17
17
  pipeOutput,
18
- } from '@/ui'
18
+ } from '@/cli/ui'
19
19
 
20
20
  /**
21
21
  * Kept rather than removed, unlike the selector's default that came out for
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { census, type CensusRefusal } from '@/census/count'
4
- import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
4
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
5
5
 
6
6
  interface CensusCommandOptions {
7
7
  readonly json?: boolean