@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.
- package/CHANGELOG.md +10 -0
- package/README.md +10 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -5
- package/claude/skills/design-identity/REQUIREMENT.md +2 -2
- package/claude/skills/design-identity/SKILL.md +6 -6
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +4 -4
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/SKILL.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
- package/claude/skills/pick-by-eye/SKILL.md +34 -41
- package/claude/skills/pick-by-eye/references/board.md +42 -0
- package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
- package/claude/skills/teach-craft/SKILL.md +32 -1
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
- package/claude/skills/teach-craft/references/pedagogy.md +29 -2
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +8 -3
- package/docs/target/projects.md +1 -1
- package/docs/workflow/skill-map.md +0 -1
- package/docs/workflow/visual-design-workflow.md +1 -4
- package/governance/detect.toml +1 -6
- package/governance/renames.toml +0 -1
- package/package.json +2 -5
- package/src/claude/cases/misc.ts +0 -5
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli/help.ts +0 -3
- package/src/cli.ts +0 -2
- package/src/commands/design.ts +16 -315
- package/src/commands/sync.ts +4 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/markdown/scope.ts +2 -4
- package/src/shipped/references.ts +1 -2
- package/src/sync/check.ts +4 -29
- package/src/sync/stamp.ts +6 -6
- package/src/sync/target.ts +1 -4
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
- package/claude/skills/canvas-board/SKILL.md +0 -143
- package/claude/skills/canvas-board/references/canvas.md +0 -170
- package/governance/rules/ui/canvas.md +0 -13
- package/src/canvas/address.ts +0 -227
- package/src/canvas/arrange.ts +0 -66
- package/src/canvas/capture.ts +0 -391
- package/src/canvas/client/app.tsx +0 -188
- package/src/canvas/client/inspector/align-grid.tsx +0 -162
- package/src/canvas/client/inspector/color-field.tsx +0 -398
- package/src/canvas/client/inspector/color-inputs.tsx +0 -139
- package/src/canvas/client/inspector/color-picker.tsx +0 -668
- package/src/canvas/client/inspector/color-space.ts +0 -240
- package/src/canvas/client/inspector/field.tsx +0 -263
- package/src/canvas/client/inspector/fields.css +0 -763
- package/src/canvas/client/inspector/section.tsx +0 -27
- package/src/canvas/client/inspector/size-field.tsx +0 -69
- package/src/canvas/client/inspector/token-select.tsx +0 -109
- package/src/canvas/client/inspector/values.ts +0 -297
- package/src/canvas/client/inspector.tsx +0 -692
- package/src/canvas/client/keys.ts +0 -71
- package/src/canvas/client/layers.tsx +0 -118
- package/src/canvas/client/main.tsx +0 -71
- package/src/canvas/client/pages-panel.tsx +0 -212
- package/src/canvas/client/panel-handle.tsx +0 -90
- package/src/canvas/client/selection.tsx +0 -199
- package/src/canvas/client/shell.css +0 -762
- package/src/canvas/client/state.ts +0 -792
- package/src/canvas/client/surface.tsx +0 -803
- package/src/canvas/client/theme-panel.tsx +0 -55
- package/src/canvas/client/tools.tsx +0 -153
- package/src/canvas/content.ts +0 -781
- package/src/canvas/edit.ts +0 -575
- package/src/canvas/editing.ts +0 -203
- package/src/canvas/fit.ts +0 -238
- package/src/canvas/global.d.ts +0 -15
- package/src/canvas/history.ts +0 -116
- package/src/canvas/index.html +0 -20
- package/src/canvas/server.ts +0 -766
- package/src/canvas/tokens.ts +0 -221
- package/src/commands/canvas.ts +0 -1097
- package/src/design/adapter.ts +0 -59
- package/src/design/base.css +0 -203
- package/src/design/brand-favicon.ts +0 -18
- package/src/design/neutral.ts +0 -127
- package/src/design/project-fonts.ts +0 -392
package/src/canvas/editing.ts
DELETED
|
@@ -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
|
-
}
|
package/src/canvas/global.d.ts
DELETED
|
@@ -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
|
-
}
|
package/src/canvas/history.ts
DELETED
|
@@ -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
|
-
}
|
package/src/canvas/index.html
DELETED
|
@@ -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>
|