@erclx/canon 5.53.0 → 5.54.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 (94) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -9
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -5
  5. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  6. package/claude/skills/design-identity/SKILL.md +6 -6
  7. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  8. package/claude/skills/design-record/SKILL.md +4 -4
  9. package/claude/skills/design-record/references/folder.md +1 -1
  10. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  11. package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
  12. package/claude/skills/draw-figure/SKILL.md +1 -1
  13. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
  14. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  15. package/claude/skills/pick-by-eye/references/board.md +42 -0
  16. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  17. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  18. package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
  19. package/claude/skills/teach-craft/SKILL.md +32 -1
  20. package/claude/skills/teach-craft/references/adopted.md +113 -0
  21. package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
  22. package/claude/skills/teach-craft/references/pedagogy.md +29 -2
  23. package/docs/agents/install-and-sync.md +0 -8
  24. package/docs/target/migrations.md +8 -3
  25. package/docs/target/projects.md +1 -1
  26. package/docs/workflow/skill-map.md +0 -1
  27. package/docs/workflow/visual-design-workflow.md +1 -4
  28. package/governance/detect.toml +1 -6
  29. package/governance/renames.toml +0 -1
  30. package/package.json +2 -5
  31. package/src/claude/cases/misc.ts +0 -5
  32. package/src/claude/skills-audit.ts +1 -0
  33. package/src/cli/help.ts +0 -3
  34. package/src/cli.ts +0 -2
  35. package/src/commands/design.ts +16 -315
  36. package/src/commands/sync.ts +4 -5
  37. package/src/design/regen.ts +5 -32
  38. package/src/design/render.ts +1 -1
  39. package/src/design/tokens.ts +3 -2
  40. package/src/gate/stages.ts +4 -10
  41. package/src/gov/audit.ts +1 -6
  42. package/src/markdown/scope.ts +2 -4
  43. package/src/shipped/references.ts +1 -2
  44. package/src/sync/check.ts +4 -29
  45. package/src/sync/stamp.ts +6 -6
  46. package/src/sync/target.ts +1 -4
  47. package/tooling/claude/reference.md +1 -1
  48. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  49. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  50. package/claude/skills/canvas-board/SKILL.md +0 -143
  51. package/claude/skills/canvas-board/references/canvas.md +0 -170
  52. package/governance/rules/ui/canvas.md +0 -13
  53. package/src/canvas/address.ts +0 -227
  54. package/src/canvas/arrange.ts +0 -66
  55. package/src/canvas/capture.ts +0 -391
  56. package/src/canvas/client/app.tsx +0 -188
  57. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  58. package/src/canvas/client/inspector/color-field.tsx +0 -398
  59. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  60. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  61. package/src/canvas/client/inspector/color-space.ts +0 -240
  62. package/src/canvas/client/inspector/field.tsx +0 -263
  63. package/src/canvas/client/inspector/fields.css +0 -763
  64. package/src/canvas/client/inspector/section.tsx +0 -27
  65. package/src/canvas/client/inspector/size-field.tsx +0 -69
  66. package/src/canvas/client/inspector/token-select.tsx +0 -109
  67. package/src/canvas/client/inspector/values.ts +0 -297
  68. package/src/canvas/client/inspector.tsx +0 -692
  69. package/src/canvas/client/keys.ts +0 -71
  70. package/src/canvas/client/layers.tsx +0 -118
  71. package/src/canvas/client/main.tsx +0 -71
  72. package/src/canvas/client/pages-panel.tsx +0 -212
  73. package/src/canvas/client/panel-handle.tsx +0 -90
  74. package/src/canvas/client/selection.tsx +0 -199
  75. package/src/canvas/client/shell.css +0 -762
  76. package/src/canvas/client/state.ts +0 -792
  77. package/src/canvas/client/surface.tsx +0 -803
  78. package/src/canvas/client/theme-panel.tsx +0 -55
  79. package/src/canvas/client/tools.tsx +0 -153
  80. package/src/canvas/content.ts +0 -781
  81. package/src/canvas/edit.ts +0 -575
  82. package/src/canvas/editing.ts +0 -203
  83. package/src/canvas/fit.ts +0 -238
  84. package/src/canvas/global.d.ts +0 -15
  85. package/src/canvas/history.ts +0 -116
  86. package/src/canvas/index.html +0 -20
  87. package/src/canvas/server.ts +0 -766
  88. package/src/canvas/tokens.ts +0 -221
  89. package/src/commands/canvas.ts +0 -1097
  90. package/src/design/adapter.ts +0 -59
  91. package/src/design/base.css +0 -203
  92. package/src/design/brand-favicon.ts +0 -18
  93. package/src/design/neutral.ts +0 -127
  94. package/src/design/project-fonts.ts +0 -392
@@ -1,203 +0,0 @@
1
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import {
4
- type ContentRefused,
5
- canvasDir,
6
- isValidName,
7
- readPage,
8
- TEMP_SUFFIX,
9
- withFileLock,
10
- } from '@/canvas/content'
11
-
12
- /**
13
- * Which frames a session says it is editing, beside `selection.json`. The
14
- * server cannot see a write until it lands, so the mark is a record a session
15
- * writes rather than something inferred, and it gates no edit.
16
- */
17
- export const EDITING_FILE = 'editing.json'
18
-
19
- /**
20
- * How long a mark lives unrenewed. A session that crashes or compacts never
21
- * clears its mark, so this is how long a dead one can mislead the operator.
22
- */
23
- export const EDITING_TTL_MS = 5 * 60_000
24
-
25
- export interface EditingMark {
26
- readonly page: string
27
- readonly frame: string
28
- readonly by: string
29
- readonly since: string
30
- readonly until: string
31
- }
32
-
33
- type Stored = Omit<EditingMark, 'page' | 'frame'>
34
-
35
- export type MarkOutcome =
36
- | { readonly ok: true; readonly mark: EditingMark }
37
- | ContentRefused
38
-
39
- export type ClearOutcome =
40
- | { readonly ok: true; readonly cleared: boolean }
41
- | ContentRefused
42
-
43
- function recordPath(root: string): string {
44
- return join(canvasDir(root), EDITING_FILE)
45
- }
46
-
47
- function isStored(value: unknown): value is Stored {
48
- if (typeof value !== 'object' || value === null) return false
49
- const { by, since, until } = value as Record<string, unknown>
50
- return (
51
- typeof by === 'string' &&
52
- typeof since === 'string' &&
53
- typeof until === 'string' &&
54
- !Number.isNaN(Date.parse(until))
55
- )
56
- }
57
-
58
- /** Every well-formed entry, with a malformed file read as holding none. */
59
- function readStored(root: string): Map<string, Stored> {
60
- const entries = new Map<string, Stored>()
61
- let parsed: unknown
62
- try {
63
- parsed = JSON.parse(readFileSync(recordPath(root), 'utf8'))
64
- } catch {
65
- return entries
66
- }
67
- if (typeof parsed !== 'object' || parsed === null) return entries
68
- for (const [key, value] of Object.entries(parsed)) {
69
- if (isStored(value)) entries.set(key, value)
70
- }
71
- return entries
72
- }
73
-
74
- function toMark(key: string, stored: Stored): EditingMark | undefined {
75
- const [page, frame, ...rest] = key.split('/')
76
- if (!page || !frame || rest.length > 0) return undefined
77
- return { page, frame, ...stored }
78
- }
79
-
80
- /** A mark still inside its expiry, on a frame still on disk. */
81
- function isLive(root: string, mark: EditingMark, now: Date): boolean {
82
- if (Date.parse(mark.until) <= now.getTime()) return false
83
- return (
84
- readPage(root, mark.page)?.frames.some(
85
- (candidate) => candidate.name === mark.frame,
86
- ) ?? false
87
- )
88
- }
89
-
90
- function liveMarks(
91
- root: string,
92
- entries: ReadonlyMap<string, Stored>,
93
- now: Date,
94
- ): EditingMark[] {
95
- return [...entries]
96
- .flatMap(([key, stored]) => toMark(key, stored) ?? [])
97
- .filter((mark) => isLive(root, mark, now))
98
- .sort((a, b) =>
99
- `${a.page}/${a.frame}`.localeCompare(`${b.page}/${b.frame}`),
100
- )
101
- }
102
-
103
- /**
104
- * Replaces the record through a rename, keeping only live marks, so the file
105
- * never grows with marks nobody cleared. The caller holds the lock, which is
106
- * what keeps two sessions marking at once from dropping each other's key.
107
- */
108
- function writeMarks(root: string, marks: readonly EditingMark[]): void {
109
- const record = Object.fromEntries(
110
- marks.map(({ page, frame, ...stored }) => [`${page}/${frame}`, stored]),
111
- )
112
- const target = recordPath(root)
113
- const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
114
- writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`)
115
- renameSync(temp, target)
116
- }
117
-
118
- export function markEditing(
119
- root: string,
120
- page: string,
121
- frame: string,
122
- by: string,
123
- now: Date,
124
- ): MarkOutcome {
125
- if (!isValidName(page) || !isValidName(frame)) {
126
- return {
127
- ok: false,
128
- reason: 'invalid-name',
129
- detail: `${page}/${frame} is not a valid frame`,
130
- }
131
- }
132
- const onPage = readPage(root, page)
133
- if (!onPage) {
134
- return {
135
- ok: false,
136
- reason: 'no-page',
137
- detail: `page ${page} does not exist`,
138
- }
139
- }
140
- if (!onPage.frames.some((candidate) => candidate.name === frame)) {
141
- return {
142
- ok: false,
143
- reason: 'no-frame',
144
- detail: `frame ${frame} does not exist on ${page}`,
145
- }
146
- }
147
-
148
- const mark: EditingMark = {
149
- page,
150
- frame,
151
- by,
152
- since: now.toISOString(),
153
- until: new Date(now.getTime() + EDITING_TTL_MS).toISOString(),
154
- }
155
- return withFileLock(recordPath(root), (): MarkOutcome => {
156
- const entries = readStored(root)
157
- entries.set(`${page}/${frame}`, {
158
- by,
159
- since: mark.since,
160
- until: mark.until,
161
- })
162
- writeMarks(root, liveMarks(root, entries, now))
163
- return { ok: true, mark }
164
- })
165
- }
166
-
167
- /**
168
- * Drops one frame's mark. Clearing a frame that holds none succeeds and writes
169
- * nothing, so a session clearing defensively never fails, and a frame removed
170
- * while marked still clears.
171
- */
172
- export function clearEditing(
173
- root: string,
174
- page: string,
175
- frame: string,
176
- ): ClearOutcome {
177
- if (!isValidName(page) || !isValidName(frame)) {
178
- return {
179
- ok: false,
180
- reason: 'invalid-name',
181
- detail: `${page}/${frame} is not a valid frame`,
182
- }
183
- }
184
- if (!existsSync(recordPath(root))) return { ok: true, cleared: false }
185
-
186
- return withFileLock(recordPath(root), (): ClearOutcome => {
187
- const entries = readStored(root)
188
- if (!entries.delete(`${page}/${frame}`)) return { ok: true, cleared: false }
189
- writeMarks(
190
- root,
191
- [...entries].flatMap(([key, stored]) => toMark(key, stored) ?? []),
192
- )
193
- return { ok: true, cleared: true }
194
- })
195
- }
196
-
197
- /**
198
- * The live marks, sorted by frame. A mark past its expiry or on a frame since
199
- * removed reads as absent with no write, so a reader never gets a dead name.
200
- */
201
- export function readEditing(root: string, now: Date): EditingMark[] {
202
- return liveMarks(root, readStored(root), now)
203
- }
package/src/canvas/fit.ts DELETED
@@ -1,238 +0,0 @@
1
- import {
2
- type Box,
3
- type ContentRefused,
4
- readPage,
5
- resizeFrame,
6
- } from '@/canvas/content'
7
- import {
8
- type CaptureDeps,
9
- resolveCaptureTargets,
10
- type ServeRefused,
11
- serveCanvas,
12
- } from '@/canvas/capture'
13
- import { resolveFrameTokens, servedFamilies } from '@/canvas/tokens'
14
-
15
- /** What the capture browser read off one frame at its box width. */
16
- export interface Measured {
17
- readonly page: string
18
- readonly frame: string
19
- readonly box: Box
20
- readonly contentHeight: number
21
- readonly contentWidth: number
22
- /**
23
- * The lowest edge of any element in the body moved when the viewport
24
- * height changed, so a viewport unit is sizing something and the content
25
- * measure is not the height a viewer draws.
26
- */
27
- readonly viewportBound: boolean
28
- /** The first family of the body text, unquoted. */
29
- readonly family: string
30
- }
31
-
32
- export interface FitRow {
33
- readonly page: string
34
- readonly frame: string
35
- /** The box height now. */
36
- readonly height: number
37
- /** The content's height, which is what the box should be. */
38
- readonly fitted: number
39
- /** The content is wider than the box, which fit reports and never widens. */
40
- readonly wide: boolean
41
- /** The first family is not one the canvas serves, so the height may not hold. */
42
- readonly font: boolean
43
- /**
44
- * The document height depends on the viewport, as a height in `vh` does, so
45
- * the content measure is not the height a viewer draws and nothing is written.
46
- */
47
- readonly viewport: boolean
48
- readonly family: string
49
- }
50
-
51
- /**
52
- * Turns measurements into rows. The box width is the frame's declared render
53
- * width, so an overflow in it is a finding and never a resize. `served` is the
54
- * set of families the served sheet carries a face for.
55
- *
56
- * A frame whose elements moved with the viewport height is sized by a unit
57
- * such as `vh`, so the row keeps its height and reports `viewport`. Scroll
58
- * height cannot show it alone, since a single `100vh` section reads as short
59
- * content drawn in a taller viewport.
60
- */
61
- export function planFit(
62
- measured: readonly Measured[],
63
- served: ReadonlySet<string>,
64
- ): FitRow[] {
65
- return measured.map((item) => {
66
- const content = Math.max(1, Math.ceil(item.contentHeight))
67
- const viewport = item.viewportBound
68
- return {
69
- page: item.page,
70
- frame: item.frame,
71
- height: item.box.height,
72
- fitted: viewport ? item.box.height : content,
73
- wide: item.contentWidth > item.box.width,
74
- font: !served.has(item.family),
75
- viewport,
76
- family: item.family,
77
- }
78
- })
79
- }
80
-
81
- /** Whether a row asks anything of the operator or the session. */
82
- export function isOwed(row: FitRow): boolean {
83
- return row.fitted !== row.height || row.wide || row.font || row.viewport
84
- }
85
-
86
- export type FitOutcome =
87
- | {
88
- readonly ok: true
89
- readonly rows: readonly FitRow[]
90
- /** Whether heights were written, which is false under a check. */
91
- readonly written: boolean
92
- }
93
- | ContentRefused
94
- | ServeRefused
95
-
96
- export interface FitDeps extends Pick<CaptureDeps, 'start'> {
97
- /** What a test stands in for, which the command never passes. */
98
- readonly measure?: (targets: readonly MeasureTarget[]) => Promise<Measured[]>
99
- }
100
-
101
- export interface MeasureTarget {
102
- readonly page: string
103
- readonly frame: string
104
- readonly url: string
105
- readonly box: Box
106
- }
107
-
108
- /**
109
- * Loads each frame at its box width through the served address, which is what
110
- * injects the tokens and the faces, and reads the document once fonts and the
111
- * load event have settled, the moment a capture waits for. The content is read
112
- * at a one pixel tall viewport, since a document shorter than the viewport
113
- * reports the viewport and a frame could then never shrink. The lowest element
114
- * edge is then read at the box height and at twice it, and an edge that moved
115
- * is a height set in viewport units.
116
- */
117
- async function measureInBrowser(
118
- targets: readonly MeasureTarget[],
119
- ): Promise<Measured[]> {
120
- const { chromium } = await import('playwright-core')
121
- const browser = await chromium.launch()
122
- try {
123
- const measured: Measured[] = []
124
- for (const target of targets) {
125
- const page = await browser.newPage({
126
- viewport: { width: target.box.width, height: 1 },
127
- })
128
- try {
129
- await page.goto(target.url, { waitUntil: 'load' })
130
- const read = await page.evaluate(async () => {
131
- await document.fonts.ready
132
- const root = document.documentElement
133
- const family = getComputedStyle(document.body).fontFamily
134
- return {
135
- contentHeight: root.scrollHeight,
136
- contentWidth: root.scrollWidth,
137
- family,
138
- }
139
- })
140
- const lowestEdge = () =>
141
- page.evaluate(() => {
142
- let bottom = 0
143
- for (const element of document.body.querySelectorAll('*')) {
144
- bottom = Math.max(
145
- bottom,
146
- element.getBoundingClientRect().bottom + window.scrollY,
147
- )
148
- }
149
- return Math.ceil(bottom)
150
- })
151
- const heights = [target.box.height, target.box.height * 2]
152
- const edges: number[] = []
153
- for (const height of heights) {
154
- await page.setViewportSize({ width: target.box.width, height })
155
- edges.push(await lowestEdge())
156
- }
157
- measured.push({
158
- page: target.page,
159
- frame: target.frame,
160
- box: target.box,
161
- contentHeight: read.contentHeight,
162
- contentWidth: read.contentWidth,
163
- viewportBound: edges[0] !== edges[1],
164
- family: firstFamily(read.family),
165
- })
166
- } finally {
167
- await page.close()
168
- }
169
- }
170
- return measured
171
- } finally {
172
- await browser.close()
173
- }
174
- }
175
-
176
- /** The first entry of a computed `font-family` list, without its quotes. */
177
- export function firstFamily(list: string): string {
178
- const first = list.split(',')[0] ?? ''
179
- return first.trim().replace(/^["']|["']$/g, '')
180
- }
181
-
182
- /**
183
- * Measures the frames `spec` names and, unless `write` is false, sets each
184
- * box's height to its content through `resizeFrame`, the writer the operator's
185
- * drags share. Serves the canvas for the call, so it needs no running board.
186
- */
187
- export async function fitCanvas(
188
- root: string,
189
- spec: string,
190
- write: boolean,
191
- deps: FitDeps = {},
192
- ): Promise<FitOutcome> {
193
- const early = resolveCaptureTargets(root, spec, '')
194
- if (!early.ok) return early
195
-
196
- const served = await serveCanvas(root, deps.start, async (base) => {
197
- const resolved = resolveCaptureTargets(root, spec, base)
198
- if (!resolved.ok) return resolved
199
- const frames = readPage(root, resolved.targets[0]?.page ?? '')?.frames ?? []
200
-
201
- const targets: MeasureTarget[] = []
202
- for (const item of resolved.targets) {
203
- const frame = frames.find((candidate) => candidate.name === item.frame)
204
- if (!frame) continue
205
- targets.push({
206
- page: item.page,
207
- frame: item.frame,
208
- url: item.url,
209
- box: {
210
- x: frame.x,
211
- y: frame.y,
212
- width: frame.width,
213
- height: frame.height,
214
- },
215
- })
216
- }
217
- const measure = deps.measure ?? measureInBrowser
218
- return { ok: true as const, targets, measured: await measure(targets) }
219
- })
220
- if (!served.ok) return served
221
-
222
- const rows = planFit(
223
- served.measured,
224
- servedFamilies(resolveFrameTokens(root).css),
225
- )
226
- if (!write) return { ok: true, rows, written: false }
227
-
228
- for (const row of rows.filter((item) => item.fitted !== item.height)) {
229
- const target = served.targets.find((item) => item.frame === row.frame)
230
- if (!target) continue
231
- const outcome = resizeFrame(root, row.page, row.frame, {
232
- ...target.box,
233
- height: row.fitted,
234
- })
235
- if (!outcome.ok) return outcome
236
- }
237
- return { ok: true, rows, written: true }
238
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * The screen color sampler, which only Chromium ships. TypeScript's DOM
3
- * library carries no type for it, so a window without it reads as undefined.
4
- */
5
- interface EyeDropperResult {
6
- readonly sRGBHex: string
7
- }
8
-
9
- interface EyeDropperSampler {
10
- open(options?: { readonly signal?: AbortSignal }): Promise<EyeDropperResult>
11
- }
12
-
13
- interface Window {
14
- readonly EyeDropper?: new () => EyeDropperSampler
15
- }
@@ -1,116 +0,0 @@
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
- }
@@ -1,20 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Canvas</title>
7
- <!--
8
- Bun's bundler resolves every href and src here at start. A root-relative
9
- route the server answers, such as /api/icon.svg, fails with "Could not
10
- resolve" and leaves / serving an empty 200, so main.tsx points the icon
11
- at it on load.
12
- -->
13
- <link rel="icon" href="data:," />
14
- <link rel="stylesheet" href="./client/shell.css" />
15
- </head>
16
- <body>
17
- <div id="app"></div>
18
- <script type="module" src="./client/main.tsx"></script>
19
- </body>
20
- </html>