@erclx/canon 4.60.0 → 4.61.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-tasks/REQUIREMENT.md +2 -2
- package/claude/skills/claude-tasks/SKILL.md +4 -4
- package/claude/skills/setup-indexes/REQUIREMENT.md +3 -0
- package/claude/skills/setup-indexes/SKILL.md +8 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/tasks.md +26 -0
- package/governance/rules/ui/440-surface-capture.md +4 -2
- package/package.json +1 -1
- package/src/commands/tasks.ts +84 -0
- package/src/tasks/label.ts +110 -0
- package/tooling/astro/reference.md +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +28 -1
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +3 -2
|
@@ -7,7 +7,7 @@ description: Why creating and archiving a task file is one skill, the origin inv
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it.
|
|
10
|
+
Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it. A label picked off the live board alone repeats one the archive already spent, since the archive holds most of what has ever been allocated and a board-only scan cannot see it.
|
|
11
11
|
|
|
12
12
|
Archiving fails in two ways that both lose work. Moving the file, editing the ordering file, and regenerating the index as three separate acts drifts from the one command the merge hook calls, so the attended and unattended paths stop agreeing. And an all-`[x]` task gets archived while its pull request is still open, because marking outcomes happens on the branch as the first step of shipping. The board is gitignored, so nothing restores a task archived early.
|
|
13
13
|
|
|
@@ -18,7 +18,7 @@ Placing a row without checking for another writer collides the same way. Two ses
|
|
|
18
18
|
- Resolve the board at the main worktree root, since a linked worktree writing to `pwd` creates a second board nothing reads
|
|
19
19
|
- Read the tasks standard before writing, rather than working the filename and frontmatter from memory
|
|
20
20
|
- Require an origin at creation, because that is the only moment the invariant is enforceable
|
|
21
|
-
-
|
|
21
|
+
- Read the phase label from `canon tasks next-label` rather than proposing one by hand
|
|
22
22
|
- Check the roster for a live orchestrator before writing a row, and hand off rather than write when one is found
|
|
23
23
|
- Confirm the work reached the default branch before archiving
|
|
24
24
|
- Run the archive command and route on the reason it refuses, since each reason has one resolution
|
|
@@ -27,11 +27,11 @@ A task with no origin is either lost context or work nobody decided to do. This
|
|
|
27
27
|
|
|
28
28
|
Accept work whose origin is the conversation itself only when the user says so explicitly, and record what it was in the intro paragraph instead of writing a link line to a file that does not exist.
|
|
29
29
|
|
|
30
|
-
### Step 2:
|
|
30
|
+
### Step 2: allocate the phase label
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Run `canon tasks next-label --json` and take its `label` field. The verb reads the live board and its `archive/` sibling together, so the label it returns accounts for what the board alone no longer shows.
|
|
33
33
|
|
|
34
|
-
Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the
|
|
34
|
+
Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the verb's two folders are the only surface that knows what a label currently means.
|
|
35
35
|
|
|
36
36
|
### Step 3: write the file
|
|
37
37
|
|
|
@@ -138,7 +138,7 @@ Create:
|
|
|
138
138
|
```plaintext
|
|
139
139
|
✅ Created: .canon/tasks/vXX.Y-<slug>.md
|
|
140
140
|
|
|
141
|
-
<label
|
|
141
|
+
<label>, next after <highest>.
|
|
142
142
|
<board or backlog, and why it landed there>.
|
|
143
143
|
|
|
144
144
|
**Origin with no task:**
|
|
@@ -15,6 +15,8 @@ One failure predates the scan. `setup-init` declines an install that wants the C
|
|
|
15
15
|
|
|
16
16
|
Drafted frontmatter is a proposal, and a session that writes it before the user sees it turns a review into a cleanup. The convention block has the mirror failure. A session that paraphrases it into `CLAUDE.md` produces a copy that reads correctly and no longer matches its source, so the two drift with nothing reporting it. And a project with no `CLAUDE.md` gets one scaffolded to hold the block, which installs a file the project declined.
|
|
17
17
|
|
|
18
|
+
A fourth failure sits after the scan rather than inside it. `## Present candidates` had no branch for a scan that finds zero folders, so an empty result walked straight into the ask with nothing to choose from. `setup-init` folds this skill into the onboarding chain, and a fresh scaffold usually carries no markdown-heavy folder yet, so the ordinary onboarding run reached the undefined case.
|
|
19
|
+
|
|
18
20
|
## Must
|
|
19
21
|
|
|
20
22
|
- Bootstrap all-or-nothing per chosen folder, since a folder carrying partial frontmatter hard-errors on regen
|
|
@@ -36,6 +38,7 @@ Drafted frontmatter is a proposal, and a session that writes it before the user
|
|
|
36
38
|
|
|
37
39
|
- A folder with fewer than the sibling threshold stays out of the candidate list unless the user names it explicitly, so the scan proposes and the user overrides
|
|
38
40
|
- A `CLAUDE.md` already carrying the convention section skips the seed silently rather than appending a second copy
|
|
41
|
+
- A scan that finds no candidate reports a one-line result and returns, skipping the ask and every step between it and the seed offer, rather than opening a prompt with nothing in it
|
|
39
42
|
|
|
40
43
|
## Out of scope
|
|
41
44
|
|
|
@@ -29,6 +29,14 @@ For each candidate, capture:
|
|
|
29
29
|
|
|
30
30
|
## Present candidates
|
|
31
31
|
|
|
32
|
+
If the scan found no candidate, report the flat result and skip ahead:
|
|
33
|
+
|
|
34
|
+
```plaintext
|
|
35
|
+
No folder has three or more markdown siblings without an index.md already. Nothing to bootstrap.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Skip past the ask below and every step between it and the seed offer, straight to `## Offer the convention seed`. There is no candidate to ask about, so this stops rather than opening a prompt with nothing in it.
|
|
39
|
+
|
|
32
40
|
Output one line per candidate:
|
|
33
41
|
|
|
34
42
|
```plaintext
|
package/docs/agents/commands.md
CHANGED
|
@@ -36,6 +36,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
36
36
|
| `canon tasks plan-link` | Write or correct a task's `Plan:` line to point at a plan, by stem and plan path or slug (`--json`) |
|
|
37
37
|
| `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
38
38
|
| `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
|
|
39
|
+
| `canon tasks next-label` | Report the next unused phase label across the board and its archive (`--json`) |
|
|
39
40
|
| `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
|
40
41
|
| `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
|
|
41
42
|
| `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
|
package/docs/agents/tasks.md
CHANGED
|
@@ -5,6 +5,32 @@ description: Selecting a shipped task by stem or pull request, recording a numbe
|
|
|
5
5
|
|
|
6
6
|
# Tasks
|
|
7
7
|
|
|
8
|
+
## Next label
|
|
9
|
+
|
|
10
|
+
`canon tasks next-label` reports the next unused phase label, reading `.canon/tasks/` and its `archive/` sibling together. A scan confined to the live board is blind to every label the archive already spent, which is what let two sessions hand out the same label within minutes of each other. It reports and never writes.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
canon tasks next-label
|
|
14
|
+
canon tasks next-label --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Option | Effect |
|
|
18
|
+
| --------------- | ------------------------------------------- |
|
|
19
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
20
|
+
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
21
|
+
|
|
22
|
+
The record carries `label`, the next free `vXX.Y`, and `highest`, the label it was derived from. `highest` is absent when neither folder carries a label yet, and `label` reads `v01.0` in that case, matching the zero-padded-major shape every other label already takes. <!-- canon-allow-reference: illustrates the verb's answer for a board holding no label yet, not a citation of a real task -->
|
|
23
|
+
|
|
24
|
+
Exit codes: `0` derived, `1` refused with `no-board`.
|
|
25
|
+
|
|
26
|
+
The minor digit rolls from 9 to 0 on the next major rather than growing a second digit, which is the single-digit-minor shape every phase label already takes. `canon tasks archive` moves a task's file from the live folder into the archive without renumbering it, so the same label counts toward the maximum wherever it currently sits, and a label claimed by two different files folds into the same scan without a dedicated check.
|
|
27
|
+
|
|
28
|
+
It reports rather than gates. Two sessions calling it in the same second can still take the same answer, since the board is gitignored files rather than a store with a lock, and `standards/versioning.md` permits free renumbering, so a collision costs a rename rather than anything worse.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
canon tasks next-label --json | jq -r '.label'
|
|
32
|
+
```
|
|
33
|
+
|
|
8
34
|
## Archive
|
|
9
35
|
|
|
10
36
|
`canon tasks archive` moves a shipped task from `.canon/tasks/` into `.canon/tasks/archive/`, drops its row from `priority.md`, and regenerates the board index. The three run as one unit, so the attended and unattended callers cannot archive differently.
|
|
@@ -30,5 +30,7 @@ paths:
|
|
|
30
30
|
|
|
31
31
|
## Sharing a capture
|
|
32
32
|
|
|
33
|
-
-
|
|
34
|
-
-
|
|
33
|
+
- Do not commit the sweep. It stays ignored.
|
|
34
|
+
- Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
|
|
35
|
+
- Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
|
|
36
|
+
- Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
|
package/package.json
CHANGED
package/src/commands/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type PlanCitations,
|
|
10
10
|
planCitations,
|
|
11
11
|
} from '@/tasks/archive'
|
|
12
|
+
import { type LabelOutcome, nextLabel } from '@/tasks/label'
|
|
12
13
|
import {
|
|
13
14
|
type CloseOutcome,
|
|
14
15
|
closeOutcomes,
|
|
@@ -87,6 +88,11 @@ interface OutcomeCommandOptions {
|
|
|
87
88
|
readonly root?: string
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
interface NextLabelCommandOptions {
|
|
92
|
+
readonly json?: boolean
|
|
93
|
+
readonly root?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
export function register(program: Command): void {
|
|
91
97
|
const tasks = program
|
|
92
98
|
.command('tasks')
|
|
@@ -379,6 +385,40 @@ export function register(program: Command): void {
|
|
|
379
385
|
.action(async (task: string | undefined, opts: OutcomeCommandOptions) => {
|
|
380
386
|
process.exitCode = await runOutcome(task, opts)
|
|
381
387
|
})
|
|
388
|
+
|
|
389
|
+
tasks
|
|
390
|
+
.command('next-label')
|
|
391
|
+
.description(
|
|
392
|
+
'Report the next unused phase label across the board and its archive',
|
|
393
|
+
)
|
|
394
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
395
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
396
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
397
|
+
.addHelpText(
|
|
398
|
+
'after',
|
|
399
|
+
[
|
|
400
|
+
'',
|
|
401
|
+
'Reads .canon/tasks/ and its archive/ sibling together, since the',
|
|
402
|
+
'archive holds labels the live board no longer shows and a scan',
|
|
403
|
+
'confined to the board hands out one already spent.',
|
|
404
|
+
'',
|
|
405
|
+
'Exit codes:',
|
|
406
|
+
' 0 the label is derived',
|
|
407
|
+
' 1 refused with no-board',
|
|
408
|
+
'',
|
|
409
|
+
'It reports and never writes. Two sessions calling it in the same',
|
|
410
|
+
'second can still take the same answer, since the board is',
|
|
411
|
+
'gitignored files rather than a store with a lock.',
|
|
412
|
+
'',
|
|
413
|
+
'Examples:',
|
|
414
|
+
' canon tasks next-label',
|
|
415
|
+
' canon tasks next-label --json',
|
|
416
|
+
'',
|
|
417
|
+
].join('\n'),
|
|
418
|
+
)
|
|
419
|
+
.action(async (opts: NextLabelCommandOptions) => {
|
|
420
|
+
process.exitCode = await runNextLabel(opts)
|
|
421
|
+
})
|
|
382
422
|
}
|
|
383
423
|
|
|
384
424
|
function collectPosition(value: string, previous: string[]): string[] {
|
|
@@ -649,6 +689,50 @@ function reportOutcome(
|
|
|
649
689
|
return 0
|
|
650
690
|
}
|
|
651
691
|
|
|
692
|
+
async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
|
|
693
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
694
|
+
const outcome = await nextLabel(root)
|
|
695
|
+
|
|
696
|
+
return reportNextLabel(outcome, opts.json ?? false, root)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function reportNextLabel(
|
|
700
|
+
outcome: LabelOutcome,
|
|
701
|
+
emitJson: boolean,
|
|
702
|
+
root: string,
|
|
703
|
+
): number {
|
|
704
|
+
if (!outcome.ok) {
|
|
705
|
+
if (emitJson) {
|
|
706
|
+
process.stdout.write(
|
|
707
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
708
|
+
)
|
|
709
|
+
return 1
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
intro('canon tasks next-label')
|
|
713
|
+
logStep('Refused')
|
|
714
|
+
logError(outcome.message)
|
|
715
|
+
outro()
|
|
716
|
+
return 1
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (emitJson) {
|
|
720
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
721
|
+
return 0
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
intro('canon tasks next-label')
|
|
725
|
+
logStep(outcome.label)
|
|
726
|
+
logInfo(
|
|
727
|
+
outcome.highest
|
|
728
|
+
? `next after ${outcome.highest}.`
|
|
729
|
+
: 'the board and its archive hold no label yet.',
|
|
730
|
+
)
|
|
731
|
+
outro()
|
|
732
|
+
|
|
733
|
+
return 0
|
|
734
|
+
}
|
|
735
|
+
|
|
652
736
|
async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
653
737
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
654
738
|
const outcome = await validateBoard(root)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
|
|
4
|
+
|
|
5
|
+
/** Every label in the corpus today stops here before rolling to the next major. */
|
|
6
|
+
const MINOR_ROLLOVER = 9
|
|
7
|
+
|
|
8
|
+
/** The label a board with no live or archived task yet allocates first. */
|
|
9
|
+
const FIRST_LABEL: Label = { major: 1, minor: 0 }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Matches a task filename stem's leading label. Anchored, so a sibling such as
|
|
13
|
+
* `TASK-ARCHIVE` fails it outright, and a reserved stem such as `index` or
|
|
14
|
+
* `priority` never reaches it at all, since `listTaskStems` already filters
|
|
15
|
+
* those out before this pattern sees a stem.
|
|
16
|
+
*/
|
|
17
|
+
export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
|
|
18
|
+
|
|
19
|
+
interface Label {
|
|
20
|
+
readonly major: number
|
|
21
|
+
readonly minor: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NextLabel {
|
|
25
|
+
readonly ok: true
|
|
26
|
+
readonly label: string
|
|
27
|
+
/** The label this run was derived from, absent when neither folder holds one. */
|
|
28
|
+
readonly highest: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LabelRefused {
|
|
32
|
+
readonly ok: false
|
|
33
|
+
readonly reason: 'no-board'
|
|
34
|
+
readonly message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LabelOutcome = NextLabel | LabelRefused
|
|
38
|
+
|
|
39
|
+
function parseLabel(stem: string): Label | undefined {
|
|
40
|
+
const match = LABEL_PATTERN.exec(stem)
|
|
41
|
+
if (!match) return undefined
|
|
42
|
+
|
|
43
|
+
return { major: Number(match[1]), minor: Number(match[2]) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isHigher(candidate: Label, current: Label): boolean {
|
|
47
|
+
return candidate.major !== current.major
|
|
48
|
+
? candidate.major > current.major
|
|
49
|
+
: candidate.minor > current.minor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatLabel(label: Label): string {
|
|
53
|
+
return `v${String(label.major).padStart(2, '0')}.${label.minor}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The label after the one given, rolling a minor of 9 to the next major rather
|
|
58
|
+
* than continuing to a second minor digit. Every one of the 587 labels measured
|
|
59
|
+
* across the live board and its archive on 2026-09-06 stops at a single digit,
|
|
60
|
+
* so this is the rollover the whole corpus already follows rather than a rule
|
|
61
|
+
* this verb introduces.
|
|
62
|
+
*/
|
|
63
|
+
function next(label: Label): Label {
|
|
64
|
+
return label.minor >= MINOR_ROLLOVER
|
|
65
|
+
? { major: label.major + 1, minor: 0 }
|
|
66
|
+
: { major: label.major, minor: label.minor + 1 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reports the next unused phase label, read off the true maximum across
|
|
71
|
+
* `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
|
|
72
|
+
* live board is blind to every label the archive already spent, which is what
|
|
73
|
+
* let two sessions hand out the same label within minutes of each other.
|
|
74
|
+
*
|
|
75
|
+
* It reports and never writes. Two sessions calling it in the same second can
|
|
76
|
+
* still take the same answer, since the board is gitignored files rather than
|
|
77
|
+
* a store with a lock, and `standards/versioning.md` permits free renumbering,
|
|
78
|
+
* so a collision costs a rename rather than anything worse. A duplicate label
|
|
79
|
+
* already sitting in the tree, and a gap left by a renumbering, both fold into
|
|
80
|
+
* the same max scan without needing a dedicated check.
|
|
81
|
+
*/
|
|
82
|
+
export async function nextLabel(root: string): Promise<LabelOutcome> {
|
|
83
|
+
const dir = tasksDir(root)
|
|
84
|
+
|
|
85
|
+
if (!existsSync(dir)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'no-board',
|
|
89
|
+
message: `No task board at ${relative(root, dir)}.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const archive = archiveDir(root)
|
|
94
|
+
const dirs = existsSync(archive) ? [dir, archive] : [dir]
|
|
95
|
+
const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
96
|
+
|
|
97
|
+
const highest = stems
|
|
98
|
+
.map(parseLabel)
|
|
99
|
+
.filter((label): label is Label => label !== undefined)
|
|
100
|
+
.reduce<Label | undefined>(
|
|
101
|
+
(max, label) => (max === undefined || isHigher(label, max) ? label : max),
|
|
102
|
+
undefined,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
|
|
108
|
+
highest: highest === undefined ? undefined : formatLabel(highest),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -59,6 +59,12 @@ Append to the `## Scripts` table:
|
|
|
59
59
|
|
|
60
60
|
In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
|
|
61
61
|
|
|
62
|
+
## Gitignore (extend)
|
|
63
|
+
|
|
64
|
+
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
65
|
+
|
|
66
|
+
- `"# Astro" = [".astro/"]`
|
|
67
|
+
|
|
62
68
|
## Scenario switcher
|
|
63
69
|
|
|
64
70
|
- `src/components/dev/scenarios.astro` ships as a golden config, always overwritten on sync, since it is toolkit-authored infrastructure rather than a file a project hand-edits.
|
|
@@ -9,11 +9,19 @@ interface CaptureCase {
|
|
|
9
9
|
route: string
|
|
10
10
|
width: number
|
|
11
11
|
height: number
|
|
12
|
+
evidence?: boolean
|
|
12
13
|
setup?: (page: Page) => Promise<void>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const CASES: CaptureCase[] = [
|
|
16
|
-
{
|
|
17
|
+
{
|
|
18
|
+
section: 'home',
|
|
19
|
+
theme: 'default',
|
|
20
|
+
route: '/',
|
|
21
|
+
width: 1280,
|
|
22
|
+
height: 800,
|
|
23
|
+
evidence: true,
|
|
24
|
+
},
|
|
17
25
|
{
|
|
18
26
|
section: 'home',
|
|
19
27
|
theme: 'dark',
|
|
@@ -47,8 +55,12 @@ const OUT_DIR = path.join('screenshots', hostname)
|
|
|
47
55
|
|
|
48
56
|
const browser = await chromium.launch()
|
|
49
57
|
const consoleErrors: string[] = []
|
|
58
|
+
let ranCases = 0
|
|
50
59
|
|
|
51
60
|
for (const captureCase of CASES) {
|
|
61
|
+
if (captureCase.evidence && requireBaseUrl) continue
|
|
62
|
+
|
|
63
|
+
ranCases++
|
|
52
64
|
const context = await browser.newContext({
|
|
53
65
|
viewport: { width: captureCase.width, height: captureCase.height },
|
|
54
66
|
})
|
|
@@ -76,11 +88,26 @@ for (const captureCase of CASES) {
|
|
|
76
88
|
await page.screenshot({ path: file, fullPage: true })
|
|
77
89
|
console.log(`captured ${file}`)
|
|
78
90
|
|
|
91
|
+
if (captureCase.evidence) {
|
|
92
|
+
const evidenceDir = path.join('evidence', captureCase.section)
|
|
93
|
+
await mkdir(evidenceDir, { recursive: true })
|
|
94
|
+
const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
|
|
95
|
+
await page.screenshot({ path: evidenceFile, fullPage: true })
|
|
96
|
+
console.log(`captured ${evidenceFile}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
await context.close()
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
await browser.close()
|
|
83
103
|
|
|
104
|
+
if (requireBaseUrl && ranCases === 0) {
|
|
105
|
+
console.error(
|
|
106
|
+
'every CASES entry is flagged evidence: true, so --require-base-url skipped all of them and checked nothing',
|
|
107
|
+
)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
if (checkConsoleClean && consoleErrors.length > 0) {
|
|
85
112
|
console.error('console errors detected:')
|
|
86
113
|
for (const error of consoleErrors) console.error(` ${error}`)
|
|
@@ -57,4 +57,5 @@ packages = [
|
|
|
57
57
|
"# Build" = ["dist/"]
|
|
58
58
|
"# Coverage" = ["coverage/"]
|
|
59
59
|
"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
|
|
60
|
+
"# Screenshots" = ["screenshots/"]
|
|
60
61
|
"# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
|
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. 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. `--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,13 +112,14 @@ Append rows:
|
|
|
112
112
|
|
|
113
113
|
`governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
|
|
114
114
|
|
|
115
|
-
The
|
|
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.
|
|
116
116
|
|
|
117
117
|
## Gitignore (extend)
|
|
118
118
|
|
|
119
119
|
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
120
120
|
|
|
121
121
|
- `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
|
|
122
|
+
- `"# Screenshots" = ["screenshots/"]`
|
|
122
123
|
|
|
123
124
|
## Verify script
|
|
124
125
|
|