@erclx/canon 5.14.1 → 5.16.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/README.md +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/index.md +1 -0
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +2 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +5 -2
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +87 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +157 -0
- package/src/canvas/client/shell.css +449 -0
- package/src/canvas/client/state.ts +372 -0
- package/src/canvas/client/surface.tsx +465 -0
- package/src/canvas/content.ts +617 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +471 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +522 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/migrate.ts +1 -1
- package/src/commands/sandbox.ts +108 -0
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/migrate/rename.ts +5 -4
- package/src/record-root.ts +1 -0
- package/src/sandbox/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
|
@@ -50,6 +50,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
50
50
|
| `canon:deprecation-migration` | When deleting or replacing code something may still call, to migrate its callers and prove none remain |
|
|
51
51
|
| `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
|
|
52
52
|
| `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
|
|
53
|
+
| `canon:search-craft` | Before searching outside the project, to derive where the field's authorities publish and search there too |
|
|
53
54
|
| `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
|
|
54
55
|
| `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
|
|
55
56
|
| `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
|
|
@@ -134,7 +135,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
134
135
|
| `canon:ci-workflow` | For a GitHub Actions workflow file |
|
|
135
136
|
| `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
|
|
136
137
|
| `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
|
|
137
|
-
| `canon:record-screencast` | For
|
|
138
|
+
| `canon:record-screencast` | For recording a screencast draft, then composing the take into a finished mp4 when the draft asks for a wrap |
|
|
138
139
|
| `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
|
|
139
140
|
|
|
140
141
|
## Answer a question at any point
|
package/governance/renames.toml
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
# An entry declares one-to-one identity and nothing else. A fold of several
|
|
7
7
|
# rules into one, or a split of one into several, is a retirement plus an
|
|
8
8
|
# addition and takes no entry here. Add the entry in the same change as the
|
|
9
|
-
# rename or renumber
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# rename or renumber. Prune an entry only once its target rule is retired and
|
|
10
|
+
# the retire path covers it, since a chain ending on a rule nothing ships
|
|
11
|
+
# resolves to nothing and a sync deletes the held file instead. Otherwise leave
|
|
12
|
+
# it: a target can sit any number of releases behind, and a chain A to B to C
|
|
13
|
+
# resolves A only while both links stay. A key must name no live rule and every
|
|
14
|
+
# chain must end on one, which `src/gov/renames.test.ts` checks against this
|
|
15
|
+
# file.
|
|
13
16
|
[renamed]
|
|
14
17
|
"005-behavior" = "565-behavior"
|
|
15
18
|
"015-output" = "566-output"
|
|
@@ -25,7 +28,6 @@
|
|
|
25
28
|
"520-wireframes" = "620-wireframes"
|
|
26
29
|
"530-requirements" = "630-requirements"
|
|
27
30
|
"540-architecture" = "640-architecture"
|
|
28
|
-
"545-decisions" = "645-decisions"
|
|
29
31
|
"550-design" = "650-design"
|
|
30
32
|
"555-tasks" = "655-tasks"
|
|
31
33
|
"556-groundwork" = "656-groundwork"
|
|
@@ -9,4 +9,4 @@ paths:
|
|
|
9
9
|
|
|
10
10
|
## Authority
|
|
11
11
|
|
|
12
|
-
- Follow the requirements standard for what belongs in the problem, goals, non-goals, MVP features,
|
|
12
|
+
- Follow the requirements standard for what belongs in the problem, goals, non-goals, MVP features, and constraints. It is the single source. Read it with `canon standards requirements`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.16.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -52,11 +52,13 @@
|
|
|
52
52
|
"web:e2e": "cd web && playwright test"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
+
"@preact/signals": "^2.11.3",
|
|
55
56
|
"commander": "^13.1.0",
|
|
56
57
|
"execa": "^9.5.2",
|
|
57
58
|
"highlight.js": "^11.12.0",
|
|
58
59
|
"playwright-core": "1.59.1",
|
|
59
|
-
"pptxgenjs": "^4.0.1"
|
|
60
|
+
"pptxgenjs": "^4.0.1",
|
|
61
|
+
"preact": "^11.0.0"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@astrojs/check": "^0.9.8",
|
|
@@ -70,6 +72,7 @@
|
|
|
70
72
|
"@types/micromatch": "^4.0.10",
|
|
71
73
|
"astro": "^6.1.9",
|
|
72
74
|
"cspell": "^8.17.1",
|
|
75
|
+
"happy-dom": "^20.14.5",
|
|
73
76
|
"husky": "^9.1.7",
|
|
74
77
|
"micromatch": "^4.0.8",
|
|
75
78
|
"prettier": "^3.8.1",
|
|
@@ -43,7 +43,7 @@ resolve_sandbox_git_identity() {
|
|
|
43
43
|
SANDBOX_GIT_NAME="${SANDBOX_GIT_NAME:-$(git config --global user.name 2>/dev/null || true)}"
|
|
44
44
|
SANDBOX_GIT_EMAIL="${SANDBOX_GIT_EMAIL:-$(git config --global user.email 2>/dev/null || true)}"
|
|
45
45
|
|
|
46
|
-
: "${SANDBOX_GIT_NAME:=
|
|
46
|
+
: "${SANDBOX_GIT_NAME:=canon-sandbox}"
|
|
47
47
|
: "${SANDBOX_GIT_EMAIL:=sandbox@example.com}"
|
|
48
48
|
|
|
49
49
|
export SANDBOX_GIT_NAME SANDBOX_GIT_EMAIL
|
|
@@ -57,7 +57,7 @@ configure_sandbox_git_identity() {
|
|
|
57
57
|
|
|
58
58
|
# Every scenario that needs a remote points at the same throwaway repository, so
|
|
59
59
|
# the name lives here rather than in each one.
|
|
60
|
-
SANDBOX_ANCHOR_REPO="
|
|
60
|
+
SANDBOX_ANCHOR_REPO="canon-sandbox"
|
|
61
61
|
|
|
62
62
|
# A scenario calls this from its own use_anchor hook rather than this file
|
|
63
63
|
# defining the hook. manage-sandbox.sh keys off `type -t use_anchor`, so
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The element address: a frame plus the element's index in document order.
|
|
3
|
+
* The shell counts it over the frame's parsed DOM and the server counts it over
|
|
4
|
+
* the file through `HTMLRewriter`, so the two have to count the same elements.
|
|
5
|
+
* This module imports nothing, which keeps it bundling into the shell, and the
|
|
6
|
+
* server half reaches `HTMLRewriter` as a Bun global only when called.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Marks the token stylesheet the server injects, which the file never holds. */
|
|
10
|
+
export const TOKENS_ATTRIBUTE = 'data-canvas-tokens'
|
|
11
|
+
|
|
12
|
+
/** On that same element, the hash of the file the frame was served from. */
|
|
13
|
+
export const HASH_ATTRIBUTE = 'data-canvas-hash'
|
|
14
|
+
|
|
15
|
+
const EXCERPT_LENGTH = 80
|
|
16
|
+
|
|
17
|
+
/** Elements whose content is raw text, which a browser never parses as markup. */
|
|
18
|
+
const RAW_TEXT = new Set(['script', 'style', 'template', 'textarea', 'title'])
|
|
19
|
+
|
|
20
|
+
export interface ElementAddress {
|
|
21
|
+
readonly index: number
|
|
22
|
+
readonly tag: string
|
|
23
|
+
/**
|
|
24
|
+
* Every element the shell counted. A browser implies elements the file never
|
|
25
|
+
* states, such as a `tbody` or the document wrapper, and each one shifts the
|
|
26
|
+
* count, so a total that differs from the server's is what exposes it.
|
|
27
|
+
*/
|
|
28
|
+
readonly count: number
|
|
29
|
+
/**
|
|
30
|
+
* The served file's hash, so the server can refuse a pick made against a
|
|
31
|
+
* version of the frame the file has since moved past.
|
|
32
|
+
*/
|
|
33
|
+
readonly hash?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SourceElement {
|
|
37
|
+
readonly tag: string
|
|
38
|
+
readonly classes: readonly string[]
|
|
39
|
+
readonly text: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AddressCheck =
|
|
43
|
+
| { readonly ok: true; readonly element: SourceElement }
|
|
44
|
+
| {
|
|
45
|
+
readonly ok: false
|
|
46
|
+
readonly reason: 'address-mismatch'
|
|
47
|
+
readonly detail: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function excerpt(text: string): string {
|
|
51
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LENGTH)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function documentElements(doc: Document): Element[] {
|
|
55
|
+
return [...doc.querySelectorAll('*')].filter(
|
|
56
|
+
(element) => !element.hasAttribute(TOKENS_ATTRIBUTE),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function addressOf(
|
|
61
|
+
doc: Document,
|
|
62
|
+
element: Element,
|
|
63
|
+
): ElementAddress | undefined {
|
|
64
|
+
const all = documentElements(doc)
|
|
65
|
+
const index = all.indexOf(element)
|
|
66
|
+
if (index === -1) return undefined
|
|
67
|
+
const address = {
|
|
68
|
+
index,
|
|
69
|
+
tag: element.tagName.toLowerCase(),
|
|
70
|
+
count: all.length,
|
|
71
|
+
}
|
|
72
|
+
const hash = doc
|
|
73
|
+
.querySelector(`[${TOKENS_ATTRIBUTE}]`)
|
|
74
|
+
?.getAttribute(HASH_ATTRIBUTE)
|
|
75
|
+
return hash ? { ...address, hash } : address
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The element an address names, or nothing once the tag there differs. */
|
|
79
|
+
export function elementAt(
|
|
80
|
+
doc: Document,
|
|
81
|
+
address: Pick<ElementAddress, 'index' | 'tag'>,
|
|
82
|
+
): Element | undefined {
|
|
83
|
+
const element = documentElements(doc)[address.index]
|
|
84
|
+
return element?.tagName.toLowerCase() === address.tag ? element : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Collected {
|
|
88
|
+
readonly tag: string
|
|
89
|
+
readonly classes: readonly string[]
|
|
90
|
+
text: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every element the file states, in document order. Text is gathered into
|
|
95
|
+
* each open element, which an element closed only by implication keeps open to
|
|
96
|
+
* the end, so its excerpt can run past where a browser would end it.
|
|
97
|
+
*/
|
|
98
|
+
export function sourceElements(html: string): SourceElement[] {
|
|
99
|
+
const elements: Collected[] = []
|
|
100
|
+
const open: number[] = []
|
|
101
|
+
let rawDepth = 0
|
|
102
|
+
/* A template's content is a fragment `querySelectorAll` never reaches. */
|
|
103
|
+
let templateDepth = 0
|
|
104
|
+
|
|
105
|
+
const appendText = (text: string) => {
|
|
106
|
+
for (const index of open) {
|
|
107
|
+
const element = elements[index]
|
|
108
|
+
if (element && element.text.length < EXCERPT_LENGTH * 4) {
|
|
109
|
+
element.text += text
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
new HTMLRewriter()
|
|
115
|
+
.on('*', {
|
|
116
|
+
element(element) {
|
|
117
|
+
const tag = element.tagName.toLowerCase()
|
|
118
|
+
if (templateDepth > 0) {
|
|
119
|
+
if (tag === 'template') {
|
|
120
|
+
templateDepth += 1
|
|
121
|
+
element.onEndTag(() => {
|
|
122
|
+
templateDepth -= 1
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const index = elements.length
|
|
128
|
+
/* A tag boundary separates words the way a rendered block would. */
|
|
129
|
+
appendText(' ')
|
|
130
|
+
elements.push({
|
|
131
|
+
tag,
|
|
132
|
+
classes: (element.getAttribute('class') ?? '')
|
|
133
|
+
.split(/\s+/)
|
|
134
|
+
.filter(Boolean),
|
|
135
|
+
text: '',
|
|
136
|
+
})
|
|
137
|
+
if (!element.canHaveContent || element.selfClosing) return
|
|
138
|
+
const isRaw = RAW_TEXT.has(tag)
|
|
139
|
+
const isTemplate = tag === 'template'
|
|
140
|
+
open.push(index)
|
|
141
|
+
if (isRaw) rawDepth += 1
|
|
142
|
+
if (isTemplate) templateDepth += 1
|
|
143
|
+
element.onEndTag(() => {
|
|
144
|
+
const at = open.lastIndexOf(index)
|
|
145
|
+
if (at !== -1) open.splice(at, 1)
|
|
146
|
+
if (isRaw) rawDepth -= 1
|
|
147
|
+
if (isTemplate) templateDepth -= 1
|
|
148
|
+
appendText(' ')
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
.onDocument({
|
|
153
|
+
text(chunk) {
|
|
154
|
+
if (rawDepth === 0) appendText(chunk.text)
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
.transform(html)
|
|
158
|
+
|
|
159
|
+
return elements.map(({ tag, classes, text }) => ({
|
|
160
|
+
tag,
|
|
161
|
+
classes,
|
|
162
|
+
text: excerpt(text),
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finds the element the shell addressed in the file, and refuses when the two
|
|
168
|
+
* counts disagree rather than naming whatever sits at that index on disk.
|
|
169
|
+
*/
|
|
170
|
+
export function resolveAddress(
|
|
171
|
+
html: string,
|
|
172
|
+
address: ElementAddress,
|
|
173
|
+
): AddressCheck {
|
|
174
|
+
const elements = sourceElements(html)
|
|
175
|
+
const element = elements[address.index]
|
|
176
|
+
if (elements.length !== address.count || element?.tag !== address.tag) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: 'address-mismatch',
|
|
180
|
+
detail: `the browser counted ${address.count} elements and the file states ${elements.length}, so element ${address.index} cannot be matched`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, element }
|
|
184
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import type { CaptureResult } from '@/capture/render'
|
|
3
|
+
import { type ContentRefused, type Frame, readPage } from '@/canvas/content'
|
|
4
|
+
import {
|
|
5
|
+
type CanvasOptions,
|
|
6
|
+
type CanvasOutcome,
|
|
7
|
+
FRAMES_PREFIX,
|
|
8
|
+
startCanvas,
|
|
9
|
+
} from '@/canvas/server'
|
|
10
|
+
import { recordDir } from '@/record-root'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The element a frame is captured by. The document rather than its body, so a
|
|
14
|
+
* frame that paints its background on the root is not cropped to the body's
|
|
15
|
+
* box.
|
|
16
|
+
*/
|
|
17
|
+
export const CAPTURE_SELECTOR = 'html'
|
|
18
|
+
|
|
19
|
+
export interface CaptureTarget {
|
|
20
|
+
readonly page: string
|
|
21
|
+
readonly frame: string
|
|
22
|
+
/** The served address, which is what carries the injected tokens. */
|
|
23
|
+
readonly url: string
|
|
24
|
+
/** The viewport width, so a media query resolves at the frame's own width. */
|
|
25
|
+
readonly width: number
|
|
26
|
+
readonly pngPath: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TargetsOutcome =
|
|
30
|
+
| { readonly ok: true; readonly targets: readonly CaptureTarget[] }
|
|
31
|
+
| ContentRefused
|
|
32
|
+
|
|
33
|
+
function refuse(
|
|
34
|
+
reason: ContentRefused['reason'],
|
|
35
|
+
detail: string,
|
|
36
|
+
): ContentRefused {
|
|
37
|
+
return { ok: false, reason, detail }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function frameUrl(base: string, page: string, frame: Frame): string {
|
|
41
|
+
return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
|
|
46
|
+
* served address, never the file on disk, so a capture can only show what the
|
|
47
|
+
* canvas shows. The width comes from the layout and not from the window, so a
|
|
48
|
+
* frame narrower than its own breakpoint renders its narrow layout.
|
|
49
|
+
*
|
|
50
|
+
* `out` is the PNG for a frame and a folder for a page, the way a URL source
|
|
51
|
+
* reads `--out` in `canon capture`.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveCaptureTargets(
|
|
54
|
+
root: string,
|
|
55
|
+
spec: string,
|
|
56
|
+
base: string,
|
|
57
|
+
out?: string,
|
|
58
|
+
): TargetsOutcome {
|
|
59
|
+
const [pageName, frameName, ...extra] = spec.split('/')
|
|
60
|
+
if (!pageName || extra.length > 0 || frameName === '') {
|
|
61
|
+
return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const page = readPage(root, pageName)
|
|
65
|
+
if (!page) return refuse('no-page', `page ${pageName} does not exist`)
|
|
66
|
+
|
|
67
|
+
const frames =
|
|
68
|
+
frameName === undefined
|
|
69
|
+
? page.frames
|
|
70
|
+
: page.frames.filter((frame) => frame.name === frameName)
|
|
71
|
+
if (frames.length === 0) {
|
|
72
|
+
return frameName === undefined
|
|
73
|
+
? refuse('no-frame', `page ${pageName} has no frames`)
|
|
74
|
+
: refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
targets: frames.map((frame) => ({
|
|
81
|
+
page: pageName,
|
|
82
|
+
frame: frame.name,
|
|
83
|
+
url: frameUrl(base, pageName, frame),
|
|
84
|
+
width: frame.width,
|
|
85
|
+
pngPath:
|
|
86
|
+
frameName !== undefined && out !== undefined
|
|
87
|
+
? out
|
|
88
|
+
: join(out ?? scratch, `${frame.name}.png`),
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CaptureOutcome =
|
|
94
|
+
| {
|
|
95
|
+
readonly ok: true
|
|
96
|
+
readonly captures: readonly {
|
|
97
|
+
readonly target: CaptureTarget
|
|
98
|
+
readonly result: CaptureResult
|
|
99
|
+
}[]
|
|
100
|
+
}
|
|
101
|
+
| ContentRefused
|
|
102
|
+
| {
|
|
103
|
+
readonly ok: false
|
|
104
|
+
readonly reason: 'no-server' | 'capture-failed'
|
|
105
|
+
readonly detail: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
|
|
109
|
+
|
|
110
|
+
/** What a test stands in for, which the command never passes. */
|
|
111
|
+
export interface CaptureDeps {
|
|
112
|
+
readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
|
|
113
|
+
readonly capture?: CaptureEngine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serves the canvas for the length of the call and captures through it, so the
|
|
118
|
+
* run needs no `canon canvas serve` and still never reads the raw file. The
|
|
119
|
+
* port is whatever is free, which keeps it clear of a canvas already running.
|
|
120
|
+
* An error the engine throws, such as a browser that will not launch, comes
|
|
121
|
+
* back as `capture-failed` rather than escaping, so a caller reading the record
|
|
122
|
+
* can tell it from a root that is wrong.
|
|
123
|
+
*/
|
|
124
|
+
export async function captureCanvas(
|
|
125
|
+
root: string,
|
|
126
|
+
spec: string,
|
|
127
|
+
out?: string,
|
|
128
|
+
deps: CaptureDeps = {},
|
|
129
|
+
): Promise<CaptureOutcome> {
|
|
130
|
+
/*
|
|
131
|
+
* Refused before the server starts, since starting it creates the canvas
|
|
132
|
+
* folder and a refused capture should leave the project as it found it.
|
|
133
|
+
*/
|
|
134
|
+
const early = resolveCaptureTargets(root, spec, '')
|
|
135
|
+
if (!early.ok) return early
|
|
136
|
+
|
|
137
|
+
const start = deps.start ?? startCanvas
|
|
138
|
+
const server = start(root, { port: 0, shell: new Response('') })
|
|
139
|
+
if (!server.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: 'no-server',
|
|
143
|
+
detail: `${server.detail}, so run canon canvas serve and capture again`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const resolved = resolveCaptureTargets(root, spec, server.url, out)
|
|
149
|
+
if (!resolved.ok) return resolved
|
|
150
|
+
|
|
151
|
+
const capture =
|
|
152
|
+
deps.capture ?? (await import('@/capture/render')).captureSources
|
|
153
|
+
const captures: { target: CaptureTarget; result: CaptureResult }[] = []
|
|
154
|
+
for (const target of resolved.targets) {
|
|
155
|
+
const [result] = await capture(target.url, {
|
|
156
|
+
selector: CAPTURE_SELECTOR,
|
|
157
|
+
width: target.width,
|
|
158
|
+
outDir: target.pngPath,
|
|
159
|
+
})
|
|
160
|
+
if (result) captures.push({ target, result })
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, captures }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: 'capture-failed',
|
|
167
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
await server.stop()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { useRef } from 'preact/hooks'
|
|
4
|
+
import { Inspector } from '@/canvas/client/inspector'
|
|
5
|
+
import { PagesPanel } from '@/canvas/client/pages-panel'
|
|
6
|
+
import { currentPage, focusFrame, tokens } from '@/canvas/client/state'
|
|
7
|
+
import { Surface } from '@/canvas/client/surface'
|
|
8
|
+
|
|
9
|
+
const SOURCE_LABEL = {
|
|
10
|
+
toolkit: 'This toolkit',
|
|
11
|
+
installed: 'Installed design',
|
|
12
|
+
none: 'None',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
function TokenDetails(): JSX.Element | null {
|
|
16
|
+
const value = tokens.value
|
|
17
|
+
if (!value) return null
|
|
18
|
+
return (
|
|
19
|
+
<section aria-labelledby="tokens-heading">
|
|
20
|
+
<h2 id="tokens-heading" class="section-label">
|
|
21
|
+
Tokens
|
|
22
|
+
</h2>
|
|
23
|
+
<p class="detail">{SOURCE_LABEL[value.source]}</p>
|
|
24
|
+
{value.files && value.files.length > 0 ? (
|
|
25
|
+
<ul class="files">
|
|
26
|
+
{value.files.map((file) => (
|
|
27
|
+
<li key={file} title={file}>
|
|
28
|
+
<code>{file}</code>
|
|
29
|
+
</li>
|
|
30
|
+
))}
|
|
31
|
+
</ul>
|
|
32
|
+
) : null}
|
|
33
|
+
{value.notice ? <p class="notice">{value.notice}</p> : null}
|
|
34
|
+
</section>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function PageDetails(): JSX.Element | null {
|
|
39
|
+
const page = currentPage.value
|
|
40
|
+
if (!page) return null
|
|
41
|
+
const unplaced = page.frames.filter((frame) => !frame.placed).length
|
|
42
|
+
return (
|
|
43
|
+
<section aria-labelledby="page-heading">
|
|
44
|
+
<h2 id="page-heading" class="section-label">
|
|
45
|
+
Page
|
|
46
|
+
</h2>
|
|
47
|
+
<p class="detail" title={page.name}>
|
|
48
|
+
{page.name}
|
|
49
|
+
</p>
|
|
50
|
+
<p class="detail-meta">
|
|
51
|
+
{page.frames.length} {page.frames.length === 1 ? 'frame' : 'frames'}
|
|
52
|
+
</p>
|
|
53
|
+
{unplaced > 0 ? (
|
|
54
|
+
<p class="notice">
|
|
55
|
+
{unplaced} {unplaced === 1 ? 'frame has' : 'frames have'} no box in
|
|
56
|
+
layout.json and sit in a default row.
|
|
57
|
+
</p>
|
|
58
|
+
) : null}
|
|
59
|
+
{page.layoutIssue === 'malformed' ? (
|
|
60
|
+
<p class="notice" role="alert">
|
|
61
|
+
layout.json does not parse, so every frame sits in a default row. Fix
|
|
62
|
+
the file to restore their places.
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
65
|
+
</section>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function App(): JSX.Element {
|
|
70
|
+
const viewportRef = useRef<HTMLDivElement>(null)
|
|
71
|
+
return (
|
|
72
|
+
<div class="shell">
|
|
73
|
+
<PagesPanel
|
|
74
|
+
onFocusFrame={(frame) => {
|
|
75
|
+
const rect = viewportRef.current?.getBoundingClientRect()
|
|
76
|
+
focusFrame(frame, rect?.width ?? 0, rect?.height ?? 0)
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
<Surface viewportRef={viewportRef} />
|
|
80
|
+
<aside class="panel panel-right" aria-label="Details">
|
|
81
|
+
<Inspector />
|
|
82
|
+
<PageDetails />
|
|
83
|
+
<TokenDetails />
|
|
84
|
+
</aside>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { elementAt } from '@/canvas/address'
|
|
4
|
+
import {
|
|
5
|
+
currentPage,
|
|
6
|
+
frameDocuments,
|
|
7
|
+
frameKey,
|
|
8
|
+
selectedElement,
|
|
9
|
+
selectedFrame,
|
|
10
|
+
writeError,
|
|
11
|
+
} from '@/canvas/client/state'
|
|
12
|
+
|
|
13
|
+
const FIELDS = ['x', 'y', 'width', 'height'] as const
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly value: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The computed values the browser settled on, which is what the operator sees
|
|
22
|
+
* rather than what any one stylesheet states.
|
|
23
|
+
*/
|
|
24
|
+
function styleRows(element: Element): Row[] {
|
|
25
|
+
const rect = element.getBoundingClientRect()
|
|
26
|
+
const view = element.ownerDocument.defaultView
|
|
27
|
+
const style = view?.getComputedStyle(element)
|
|
28
|
+
const rows: Row[] = [
|
|
29
|
+
{ name: 'x', value: String(Math.round(rect.left)) },
|
|
30
|
+
{ name: 'y', value: String(Math.round(rect.top)) },
|
|
31
|
+
{ name: 'width', value: String(Math.round(rect.width)) },
|
|
32
|
+
{ name: 'height', value: String(Math.round(rect.height)) },
|
|
33
|
+
]
|
|
34
|
+
if (!style) return rows
|
|
35
|
+
return [
|
|
36
|
+
...rows,
|
|
37
|
+
{ name: 'color', value: style.color },
|
|
38
|
+
{ name: 'background', value: style.backgroundColor },
|
|
39
|
+
{ name: 'font', value: style.fontFamily },
|
|
40
|
+
{ name: 'size', value: style.fontSize },
|
|
41
|
+
{ name: 'weight', value: style.fontWeight },
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function elementName(element: Element): string {
|
|
46
|
+
const tag = element.tagName.toLowerCase()
|
|
47
|
+
return [tag, ...element.classList].join('.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read-only in this slice, since editing an element is the edit slice's work. */
|
|
51
|
+
function ElementDetails(): JSX.Element | null {
|
|
52
|
+
const picked = selectedElement.value
|
|
53
|
+
const page = currentPage.value
|
|
54
|
+
if (!picked || !page) return null
|
|
55
|
+
const { frame, element } = picked
|
|
56
|
+
const doc = frameDocuments.value.get(frameKey(page.name, frame))
|
|
57
|
+
const node = doc && !element.stale ? elementAt(doc, element) : undefined
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section aria-label="Element">
|
|
61
|
+
<h2 class="section-label">Element</h2>
|
|
62
|
+
{element.stale ? (
|
|
63
|
+
<p class="notice">
|
|
64
|
+
The frame changed since this element was picked, so its index may name
|
|
65
|
+
another element now. Pick it again.
|
|
66
|
+
</p>
|
|
67
|
+
) : node ? (
|
|
68
|
+
<>
|
|
69
|
+
<p class="detail" title={elementName(node)}>
|
|
70
|
+
{elementName(node)}
|
|
71
|
+
</p>
|
|
72
|
+
<dl class="box styles">
|
|
73
|
+
{styleRows(node).map((row) => (
|
|
74
|
+
<div key={row.name} class="box-row">
|
|
75
|
+
<dt>{row.name}</dt>
|
|
76
|
+
<dd title={row.value}>{row.value}</dd>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</dl>
|
|
80
|
+
</>
|
|
81
|
+
) : (
|
|
82
|
+
<p class="empty">Loading the frame to read this element</p>
|
|
83
|
+
)}
|
|
84
|
+
</section>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read-only in this slice, since editing a box is the edit slice's work. */
|
|
89
|
+
export function Inspector(): JSX.Element {
|
|
90
|
+
const frame = selectedFrame.value
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<section aria-label="Frame">
|
|
94
|
+
<h2 class="section-label">Frame</h2>
|
|
95
|
+
{frame ? (
|
|
96
|
+
<>
|
|
97
|
+
<p class="detail" title={frame.name}>
|
|
98
|
+
{frame.name}
|
|
99
|
+
</p>
|
|
100
|
+
<dl class="box">
|
|
101
|
+
{FIELDS.map((field) => (
|
|
102
|
+
<div key={field} class="box-row">
|
|
103
|
+
<dt>{field}</dt>
|
|
104
|
+
<dd>{frame[field]}</dd>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</dl>
|
|
108
|
+
</>
|
|
109
|
+
) : (
|
|
110
|
+
<p class="empty">Select a frame to see its box</p>
|
|
111
|
+
)}
|
|
112
|
+
{writeError.value ? (
|
|
113
|
+
<p class="notice" role="alert">
|
|
114
|
+
{writeError.value}
|
|
115
|
+
</p>
|
|
116
|
+
) : null}
|
|
117
|
+
</section>
|
|
118
|
+
<ElementDetails />
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|