@erclx/canon 4.98.0 → 4.100.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 +24 -6
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +6 -4
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +3 -3
- package/claude/skills/git-pr/SKILL.md +41 -21
- package/claude/skills/test-craft/REQUIREMENT.md +1 -1
- package/claude/skills/test-first/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/REQUIREMENT.md +49 -0
- package/claude/skills/ui-checklist/SKILL.md +71 -0
- package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
- package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
- package/docs/agents/capture.md +6 -2
- package/docs/agents/demo.md +3 -1
- package/docs/agents/pr-evidence.md +34 -11
- package/docs/agents/pr-preview.md +4 -3
- package/docs/agents/tasks.md +3 -1
- package/docs/workflow/ai-workflow.md +4 -4
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +36 -3
- package/src/claude/cases/workflow.ts +3 -2
- package/src/commands/demo.ts +4 -3
- package/src/commands/pr.ts +53 -1
- package/src/commands/tasks.ts +16 -0
- package/src/demo/compile.ts +20 -0
- package/src/demo/drive.ts +1 -0
- package/src/migrate/skill-names.ts +14 -6
- package/src/pr/evidence.ts +36 -1
- package/src/sandbox/expect.ts +2 -1
- package/src/tasks/archive.ts +115 -3
- package/standards/ready.md +2 -2
- package/standards/tasks.md +4 -2
- package/tooling/web/configs/e2e/screenshot.ts +155 -11
- package/tooling/web/reference.md +2 -2
- package/claude/skills/ui-test/REQUIREMENT.md +0 -48
- package/claude/skills/ui-test/SKILL.md +0 -87
package/standards/tasks.md
CHANGED
|
@@ -213,7 +213,9 @@ A task with no origin is either lost context or work nobody decided to do. The i
|
|
|
213
213
|
|
|
214
214
|
An intake folder answers that direction at folder scope rather than item scope, since one dump dispositions many items and most close without ever becoming a task. What names a folder is every item answered and no task citing it, on the board or in the archive. That is a dump nobody acted on. Counting the archive and declined folder beside the board is what separates it from one already promoted and settled, and a check reading the board alone calls every finished folder abandoned.
|
|
215
215
|
|
|
216
|
-
`Plan:`, `Groundwork:`, and `Intake:` name their target as a markdown link whose text is the file or folder stem, so the line resolves on a ctrl-click the way `priority.md` rows already do. Write the path relative to `.canon/tasks/`, which makes it `../plans/`, `../groundwork/`, and `../intake/`. A path written from the project root renders as a link and resolves to nothing in an editor rooted at the project. `
|
|
216
|
+
`Plan:`, `Groundwork:`, and `Intake:` name their target as a markdown link whose text is the file or folder stem, so the line resolves on a ctrl-click the way `priority.md` rows already do. Write the path relative to `.canon/tasks/`, which makes it `../plans/`, `../groundwork/`, and `../intake/`. A path written from the project root renders as a link and resolves to nothing in an editor rooted at the project. `Ready:` names the ready folder a task's plan copies from, as a link to `../ready/<nn>-<slug>/` with the trailing slash a folder carries. Include it only when the folder exists. `canon tasks archive` reads it to move the folder, and falls back to a `.canon/ready/<nn>-<slug>/` path in the plan when a task carries no line.
|
|
217
|
+
|
|
218
|
+
`Issue:` stays a bare `#NNN`, since an issue number is not a path and a full URL would write the remote into a gitignored file.
|
|
217
219
|
|
|
218
220
|
Phase-label format and where labels may appear are governed by `standards/versioning.md`.
|
|
219
221
|
|
|
@@ -257,7 +259,7 @@ The archive nests inside `.canon/tasks/` rather than sitting beside it as a flat
|
|
|
257
259
|
|
|
258
260
|
One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.canon/plans/archive/`, sitting inside the folder it archives the same way, and it inherits the board's own ignore entry rather than needing one of its own. The cost is that an archived task does not appear in diffs, which is the cost the live board already carries.
|
|
259
261
|
|
|
260
|
-
Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone.
|
|
262
|
+
Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. The ready folder the task's `Ready:` line names moves with the plan to `.canon/ready/archive/<nn>-<slug>/`, and the `Ready:` line and the plan's path to the folder are retargeted, since the folder is the plan's verbatim source. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone. Its ready folder stays with it.
|
|
261
263
|
|
|
262
264
|
One act rather than two is what makes the pair safe. The merge is the event that settles a plan, and a `post-merge` hook reaching the archive with nobody watching cannot act on a warning, so a second call after it would be a second failure point leaving the task archived and the plan live.
|
|
263
265
|
|
|
@@ -3,6 +3,12 @@ import { chromium } from '@playwright/test'
|
|
|
3
3
|
import { mkdir } from 'fs/promises'
|
|
4
4
|
import path from 'path'
|
|
5
5
|
|
|
6
|
+
/** One addressable part of a route, captured beside the whole-page frame. */
|
|
7
|
+
interface CaptureSection {
|
|
8
|
+
name: string
|
|
9
|
+
selector: string
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
interface CaptureCase {
|
|
7
13
|
section: string
|
|
8
14
|
theme: string
|
|
@@ -10,9 +16,35 @@ interface CaptureCase {
|
|
|
10
16
|
width: number
|
|
11
17
|
height: number
|
|
12
18
|
evidence?: boolean
|
|
19
|
+
sections?: CaptureSection[]
|
|
13
20
|
setup?: (page: Page) => Promise<void>
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The landing page's own beats, top to bottom. Every entry is a section a
|
|
25
|
+
* reviewer reads on its own, so a diff lands on the part that moved rather
|
|
26
|
+
* than on one image of the whole page.
|
|
27
|
+
*/
|
|
28
|
+
const HOME_SECTIONS: CaptureSection[] = [
|
|
29
|
+
{ name: 'top', selector: '#top' },
|
|
30
|
+
{ name: 'proof', selector: '#proof' },
|
|
31
|
+
{ name: 'ask', selector: '#ask' },
|
|
32
|
+
{ name: 'rules', selector: '#rules' },
|
|
33
|
+
{ name: 'skills', selector: '#skills' },
|
|
34
|
+
{ name: 'plan', selector: '#plan' },
|
|
35
|
+
{ name: 'dispatch', selector: '#dispatch' },
|
|
36
|
+
{ name: 'workers', selector: '#workers' },
|
|
37
|
+
{ name: 'gate', selector: '#gate' },
|
|
38
|
+
{ name: 'memory', selector: '#memory' },
|
|
39
|
+
{ name: 'evidence', selector: '#evidence' },
|
|
40
|
+
{ name: 'loop', selector: '#loop' },
|
|
41
|
+
{ name: 'merge', selector: '#merge' },
|
|
42
|
+
{ name: 'provenance', selector: '#provenance' },
|
|
43
|
+
{ name: 'start', selector: '#start' },
|
|
44
|
+
{ name: 'field', selector: '#field' },
|
|
45
|
+
{ name: 'close', selector: '#close' },
|
|
46
|
+
]
|
|
47
|
+
|
|
16
48
|
const CASES: CaptureCase[] = [
|
|
17
49
|
{
|
|
18
50
|
section: 'home',
|
|
@@ -21,6 +53,7 @@ const CASES: CaptureCase[] = [
|
|
|
21
53
|
width: 1280,
|
|
22
54
|
height: 800,
|
|
23
55
|
evidence: true,
|
|
56
|
+
sections: HOME_SECTIONS,
|
|
24
57
|
},
|
|
25
58
|
{
|
|
26
59
|
section: 'home',
|
|
@@ -28,6 +61,7 @@ const CASES: CaptureCase[] = [
|
|
|
28
61
|
route: '/',
|
|
29
62
|
width: 1280,
|
|
30
63
|
height: 800,
|
|
64
|
+
sections: HOME_SECTIONS,
|
|
31
65
|
setup: (page) => page.emulateMedia({ colorScheme: 'dark' }),
|
|
32
66
|
},
|
|
33
67
|
]
|
|
@@ -53,6 +87,108 @@ try {
|
|
|
53
87
|
|
|
54
88
|
const OUT_DIR = path.join('screenshots', hostname)
|
|
55
89
|
|
|
90
|
+
/** Every wait on a lazy load is bounded explicitly. */
|
|
91
|
+
const SETTLE_TIMEOUT = 15_000
|
|
92
|
+
|
|
93
|
+
/** Marks the chrome a section frame drops, set on the page and read by the screenshot's own stylesheet. */
|
|
94
|
+
const FLOATING_ATTRIBUTE = 'data-capture-floating'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Brings the page to the state a reader arrives at, before anything is shot.
|
|
98
|
+
* A figure gated on an IntersectionObserver and a frame marked `loading=lazy`
|
|
99
|
+
* both stay at their start state until something scrolls past them, and a full
|
|
100
|
+
* page capture scrolls nothing, so each named section is scrolled into view
|
|
101
|
+
* once and the page then returns to the top. Fonts, images and frames settle
|
|
102
|
+
* afterwards, since the scroll is what starts the lazy loads.
|
|
103
|
+
*/
|
|
104
|
+
async function settle(
|
|
105
|
+
page: Page,
|
|
106
|
+
label: string,
|
|
107
|
+
sections: readonly CaptureSection[],
|
|
108
|
+
): Promise<CaptureSection[]> {
|
|
109
|
+
const present: CaptureSection[] = []
|
|
110
|
+
for (const section of sections) {
|
|
111
|
+
const element = page.locator(section.selector)
|
|
112
|
+
// Read the count before scrolling, since a selector matching nothing
|
|
113
|
+
// otherwise costs the default timeout once per section. A miss is
|
|
114
|
+
// reported and skipped rather than raised, because `CASES` is a template
|
|
115
|
+
// a project rewrites and a target that syncs this file before editing its
|
|
116
|
+
// own list would otherwise capture nothing at all. The project owning the
|
|
117
|
+
// selectors is the one that tests them, which is what the section spec
|
|
118
|
+
// beside its own page does.
|
|
119
|
+
if ((await element.count()) === 0) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`${label}: no element matches ${section.selector}, named as section "${section.name}". Skipped.`,
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
present.push(section)
|
|
126
|
+
await element.scrollIntoViewIfNeeded()
|
|
127
|
+
}
|
|
128
|
+
await page.evaluate(() => window.scrollTo(0, 0))
|
|
129
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
130
|
+
// Settle on what the scroll started rather than on a second `networkidle`,
|
|
131
|
+
// which is a lifecycle event of the navigation and does not re-arm for a
|
|
132
|
+
// load a scroll triggered afterwards.
|
|
133
|
+
await page.waitForFunction(
|
|
134
|
+
() => {
|
|
135
|
+
if (document.readyState !== 'complete') return false
|
|
136
|
+
for (const image of document.images) if (!image.complete) return false
|
|
137
|
+
for (const frame of document.querySelectorAll('iframe')) {
|
|
138
|
+
try {
|
|
139
|
+
const inner = frame.contentDocument
|
|
140
|
+
if (inner !== null && inner.readyState !== 'complete') return false
|
|
141
|
+
} catch {
|
|
142
|
+
// Cross-origin, so there is no readyState to read and nothing here
|
|
143
|
+
// can wait on it.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
},
|
|
148
|
+
undefined,
|
|
149
|
+
{ timeout: SETTLE_TIMEOUT },
|
|
150
|
+
)
|
|
151
|
+
return present
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Marks every element the page floats over its own content, which is chrome
|
|
156
|
+
* such as a sticky bar or a fixed dialog. A section taller than the viewport
|
|
157
|
+
* is scrolled into view to be shot, and the chrome rides that scroll into the
|
|
158
|
+
* middle of the frame, painting over the part the frame exists to show. The
|
|
159
|
+
* mark is an attribute rather than a style, so the rule that acts on it is
|
|
160
|
+
* carried by the screenshot itself and no state is left behind to restore.
|
|
161
|
+
*
|
|
162
|
+
* Marking costs the whole-page frame nothing, since that one is shot before
|
|
163
|
+
* this runs and carries the chrome where a reader meets it.
|
|
164
|
+
*/
|
|
165
|
+
async function markFloatingChrome(page: Page) {
|
|
166
|
+
await page.evaluate((attribute) => {
|
|
167
|
+
for (const node of document.querySelectorAll('body *')) {
|
|
168
|
+
const { position } = getComputedStyle(node)
|
|
169
|
+
if (position === 'sticky' || position === 'fixed') {
|
|
170
|
+
node.setAttribute(attribute, '')
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}, FLOATING_ATTRIBUTE)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function shoot(page: Page, dir: string, file: string, selector?: string) {
|
|
177
|
+
await mkdir(dir, { recursive: true })
|
|
178
|
+
const target = path.join(dir, file)
|
|
179
|
+
if (selector === undefined) {
|
|
180
|
+
await page.screenshot({ path: target, fullPage: true })
|
|
181
|
+
} else {
|
|
182
|
+
// Floating chrome keeps the space it already occupies, so dropping it out
|
|
183
|
+
// of its own layer moves nothing and leaves every box the size it was.
|
|
184
|
+
await page.locator(selector).screenshot({
|
|
185
|
+
path: target,
|
|
186
|
+
style: `[${FLOATING_ATTRIBUTE}] { position: static !important; }`,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
console.log(`captured ${target}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
56
192
|
const browser = await chromium.launch()
|
|
57
193
|
const consoleErrors: string[] = []
|
|
58
194
|
let ranCases = 0
|
|
@@ -63,6 +199,10 @@ for (const captureCase of CASES) {
|
|
|
63
199
|
ranCases++
|
|
64
200
|
const context = await browser.newContext({
|
|
65
201
|
viewport: { width: captureCase.width, height: captureCase.height },
|
|
202
|
+
// A page honoring the preference lands its own motion at the end state,
|
|
203
|
+
// which is what a still frame has to show. Playwright reads it as a
|
|
204
|
+
// context option, so it reaches no page written beside `viewport` above.
|
|
205
|
+
reducedMotion: 'reduce',
|
|
66
206
|
})
|
|
67
207
|
const page = await context.newPage()
|
|
68
208
|
|
|
@@ -81,19 +221,23 @@ for (const captureCase of CASES) {
|
|
|
81
221
|
await page.goto(`${BASE_URL}${captureCase.route}`)
|
|
82
222
|
await page.waitForLoadState('networkidle')
|
|
83
223
|
|
|
84
|
-
const
|
|
85
|
-
await
|
|
224
|
+
const label = `${captureCase.section}/${captureCase.theme}`
|
|
225
|
+
const sections = await settle(page, label, captureCase.sections ?? [])
|
|
226
|
+
|
|
227
|
+
const sweepDir = path.join(OUT_DIR, captureCase.section)
|
|
228
|
+
const evidenceDir = path.join('evidence', captureCase.section)
|
|
229
|
+
const file = `${captureCase.theme}.png`
|
|
230
|
+
|
|
231
|
+
await shoot(page, sweepDir, file)
|
|
232
|
+
if (captureCase.evidence) await shoot(page, evidenceDir, file)
|
|
86
233
|
|
|
87
|
-
|
|
88
|
-
await page.screenshot({ path: file, fullPage: true })
|
|
89
|
-
console.log(`captured ${file}`)
|
|
234
|
+
if (sections.length > 0) await markFloatingChrome(page)
|
|
90
235
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(`captured ${evidenceFile}`)
|
|
236
|
+
for (const { name, selector } of sections) {
|
|
237
|
+
await shoot(page, path.join(sweepDir, name), file, selector)
|
|
238
|
+
if (captureCase.evidence) {
|
|
239
|
+
await shoot(page, path.join(evidenceDir, name), file, selector)
|
|
240
|
+
}
|
|
97
241
|
}
|
|
98
242
|
|
|
99
243
|
await context.close()
|
package/tooling/web/reference.md
CHANGED
|
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
12
12
|
|
|
13
13
|
- `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
|
|
14
14
|
- `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
|
|
15
|
-
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
15
|
+
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. A case may also name `sections`, one `{ name, selector }` per addressable part of the route, and each writes its own frame at `screenshots/<hostname>/<section>/<name>/<theme>.png` beside the whole-page one, plus `evidence/<section>/<name>/<theme>.png` where the case is flagged. The field is optional, so a case that names none captures the page alone as before, and a selector matching nothing is reported and skipped rather than raised, since `CASES` is a template a project rewrites and a target that syncs this file before editing its own list would otherwise capture nothing at all. Testing that every named section resolves belongs to the project that owns the selectors, beside its own page. Every case settles before anything is shot: the context is created with `reducedMotion: 'reduce'`, each named section is scrolled into view so an `IntersectionObserver` fires and a frame marked `loading="lazy"` loads, the page returns to the top, and fonts, images and same-origin frames are waited out on a bounded condition rather than on a second `networkidle`, which is a lifecycle event of the navigation and does not re-arm for a load a scroll started. A section frame drops the page's floating chrome, being whatever computes to `position: sticky` or `fixed`, because a section taller than the viewport is scrolled into view to be cut and a sticky bar otherwise rides that scroll into the middle of the frame. The whole-page frame is shot first and keeps its chrome. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
@@ -112,7 +112,7 @@ Append rows:
|
|
|
112
112
|
|
|
113
113
|
`governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a surface changes. It now fires on every component file too, reversing the route-and-page-only scope this reference once described, after a shipped batch of components carried no capture and a defect went unseen. The rule body states its own exemption for a component the production build strips out, so a reader chasing that case reads it there rather than here.
|
|
114
114
|
|
|
115
|
-
The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
|
|
115
|
+
The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there. A case naming sections commits a folder per section under that baseline rather than one file per theme, which is what lets a pull request comparison land on the part of the page that moved instead of on one image of the whole of it. The count is the section list times the themes, so a list is worth sizing against how many frames each later change has to regenerate and a reviewer has to read.
|
|
116
116
|
|
|
117
117
|
## Gitignore (extend)
|
|
118
118
|
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ui-test
|
|
3
|
-
description: Why UI changes split into what a browser can assert and what only an eye can judge, and why the visual half is written to disk rather than printed
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# UI test requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, UI work ships on the confidence of the session that wrote it. What verification does happen mixes the two kinds without noticing, so a checklist item about spacing sits beside one about a form submission, and neither is done properly because they need different work from different people. Tests get written against a config the project does not use, or duplicate what a component test already covers.
|
|
11
|
-
|
|
12
|
-
The automatable half fails the other way. Sending every interaction, state, and form to Playwright leaves the layers unrouted, so a loading state lands in a full browser run and the end to end suite grows on each UI change. A state a component test reaches costs a browser to check, and the cost recurs on every later run.
|
|
13
|
-
|
|
14
|
-
The visual half is where the record is lost. A checklist printed into chat scrolls away before anyone verifies it, so the ship step has nothing to gate on and the work reads as verified because a list was produced. Written from a linked worktree against `pwd`, the file lands where the caller does not look.
|
|
15
|
-
|
|
16
|
-
Writing the checklist correctly to disk does not close the gap either. The file sits under a gitignored folder on the machine that authored it, so a pull request reviewer, on that machine or another, never sees it. A record that only the author can open reads as verified for the same reason a record that never existed does.
|
|
17
|
-
|
|
18
|
-
## Must
|
|
19
|
-
|
|
20
|
-
- Split every change into visual-only and automatable before writing anything, then route each automatable one to its layer through `test-craft`
|
|
21
|
-
- Write end to end only for a journey or a behavior only a real browser renders, and component or unit tests for the rest
|
|
22
|
-
- Name a missing component runner in the output and put the state on the checklist
|
|
23
|
-
- Read the project's existing test config and patterns before writing a test against them
|
|
24
|
-
- Assert a user action and its outcome per test, covering the happy path and the key edge cases
|
|
25
|
-
- Run the tests after writing them and fix what fails
|
|
26
|
-
- Write a produced checklist to the `.canon/tmp/handoff/ui-checklist/<slug>.md` handoff at the main worktree root, overwriting
|
|
27
|
-
- Report that everything is covered rather than manufacturing a checklist to show work
|
|
28
|
-
|
|
29
|
-
## Must not
|
|
30
|
-
|
|
31
|
-
- Try to assert a visual property programmatically
|
|
32
|
-
- Re-test what unit or component tests written during implementation already cover
|
|
33
|
-
- Route a state a component test reaches into end to end, or fall back to end to end silently when no runner exists
|
|
34
|
-
- Repeat the full checklist in chat, which is what made it evaporate
|
|
35
|
-
- Stage or commit the checklist, which is gitignored scratch
|
|
36
|
-
- Talk to GitHub directly. Posting the checklist to a pull request belongs to `git-pr`, the sole consumer of the handoff file
|
|
37
|
-
|
|
38
|
-
## Guards
|
|
39
|
-
|
|
40
|
-
- No implementation context in the session: stop, since there is nothing to derive tests from
|
|
41
|
-
- Every change automatable: skip the file write and report the coverage
|
|
42
|
-
|
|
43
|
-
## Out of scope
|
|
44
|
-
|
|
45
|
-
- Judging whether the UI is any good, which `ux-audit` owns
|
|
46
|
-
- The rules for choosing a layer, which `test-craft` owns
|
|
47
|
-
- Deciding whether an outstanding checklist blocks the ship, which the calling pipeline gates on
|
|
48
|
-
- Performing the visual verification, which needs a person
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ui-test
|
|
3
|
-
description: Routes each UI change to the test layer that can catch its break, writes end to end tests only for journeys and browser-only behavior, and produces a manual checklist for visual-only items. Use after implementing UI changes, or when asked "what should I test", "what do I verify", or "give me a test checklist". Do NOT use in empty sessions with no implementation context.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# UI test
|
|
7
|
-
|
|
8
|
-
## Guards
|
|
9
|
-
|
|
10
|
-
- If no implementation context exists in the session, stop: `❌ No implementation context. Describe what you built first.`
|
|
11
|
-
- Load `canon:test-craft` before writing any test, and report it rather than proceeding silently when it does not resolve.
|
|
12
|
-
|
|
13
|
-
## Analysis
|
|
14
|
-
|
|
15
|
-
Review the session to identify what was built or changed. Categorize each change, then route it:
|
|
16
|
-
|
|
17
|
-
- **Visual-only:** spacing, alignment, color, typography, layout proportions, animation timing. These become a manual checklist.
|
|
18
|
-
- **Journey or browser-only:** a flow across routes, a route change, or a behavior only a real browser renders, such as scroll, focus order across pages, or layout. These become end to end tests.
|
|
19
|
-
- **Everything else:** interactions, state transitions, form submissions, keyboard handling, conditional rendering, loading, empty, and error states. Route these through the `test-craft` layer table to a component test, or to a unit test when the logic renders nothing.
|
|
20
|
-
|
|
21
|
-
Never write an end to end test for a state a component test reaches. Exclude anything already covered by tests written during implementation.
|
|
22
|
-
|
|
23
|
-
## Component and unit tests
|
|
24
|
-
|
|
25
|
-
- Read the project's test config and an existing test at the layer first, and follow its runner, suffix, and placement.
|
|
26
|
-
- Read the `test-craft` component reference before the first component test.
|
|
27
|
-
- Assert each state through what a user sees or does, covering the happy path and the key edges.
|
|
28
|
-
- When the project has no component runner, write the checklist item for that state instead of an end to end test, and name the missing runner in the output. Do not fall back to end to end silently.
|
|
29
|
-
|
|
30
|
-
## E2e tests
|
|
31
|
-
|
|
32
|
-
Write Playwright tests for journeys and browser-only behavior only. Follow these rules:
|
|
33
|
-
|
|
34
|
-
- Read the `test-craft` end to end reference before the first one.
|
|
35
|
-
- Add tests to the project's existing `e2e/` layout and naming. If none exists, create `e2e/<feature>.spec.ts`.
|
|
36
|
-
- Use the project's existing Playwright config and test patterns. Read them first.
|
|
37
|
-
- Each test performs a user action across the journey and asserts the expected outcome.
|
|
38
|
-
- For Chrome extensions: load the unpacked extension via Playwright's `--load-extension` flag and use the extension's sidepanel or popup URL as the test target.
|
|
39
|
-
- Run every test written, at every layer. Fix failures before finishing.
|
|
40
|
-
|
|
41
|
-
Test structure:
|
|
42
|
-
|
|
43
|
-
```typescript
|
|
44
|
-
test('description of user flow', async ({ page }) => {
|
|
45
|
-
// Arrange: navigate, set up state
|
|
46
|
-
// Act: perform user action
|
|
47
|
-
// Assert: verify expected outcome
|
|
48
|
-
})
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Manual checklist
|
|
52
|
-
|
|
53
|
-
For visual-only items that cannot be asserted programmatically, and for any state left without a runner, produce a checklist. Group by feature area. Use `- [ ]` checkbox syntax.
|
|
54
|
-
|
|
55
|
-
```markdown
|
|
56
|
-
**What to verify visually:**
|
|
57
|
-
|
|
58
|
-
**<Feature area>**
|
|
59
|
-
|
|
60
|
-
- [ ] <action> → <expected visual result>
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
If every change is covered by a test, skip the manual checklist:
|
|
64
|
-
|
|
65
|
-
`✅ All changes covered by tests. No manual verification needed.`
|
|
66
|
-
|
|
67
|
-
### Persist the checklist
|
|
68
|
-
|
|
69
|
-
Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
|
|
70
|
-
|
|
71
|
-
When a manual checklist is produced, write it directly to `.canon/tmp/handoff/ui-checklist/<slug>.md` at the main worktree root, not the current worktree. Resolve that root the way `session-worktree` does. Create the directory if it does not exist. Always overwrite. This is a handoff file rather than a deliverable: `git-pr` posts it as a pull request comment once one opens, then removes it, and nothing here talks to `gh` directly.
|
|
72
|
-
|
|
73
|
-
From a linked worktree the file-editing tools refuse that path, so the checklist goes out through `Bash`. Send the `mkdir -p` and the heredoc as two plain commands rather than joining them with `&&`, which is refused as compound.
|
|
74
|
-
|
|
75
|
-
Skip the file write when every change is covered by tests and no checklist was produced.
|
|
76
|
-
|
|
77
|
-
The `.canon/tmp/` directory is gitignored. Do not stage or commit the file.
|
|
78
|
-
|
|
79
|
-
## Output order
|
|
80
|
-
|
|
81
|
-
1. Write and run the tests, naming each one's layer (report pass/fail)
|
|
82
|
-
2. Name any state left to the checklist for want of a component runner
|
|
83
|
-
3. If a manual checklist was produced, write it to file, then output only the file path in chat:
|
|
84
|
-
`📝 Wrote .canon/tmp/handoff/ui-checklist/<slug>.md`
|
|
85
|
-
4. If no checklist was needed: `✅ All changes covered by tests. No manual verification needed.`
|
|
86
|
-
|
|
87
|
-
Do not repeat the full checklist in chat.
|