@erclx/canon 4.116.0 → 4.118.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/canon-operator/SKILL.md +1 -1
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/draft-ready/references/assembly.md +8 -0
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +7 -0
- package/claude/skills/review-pr/REQUIREMENT.md +3 -0
- package/claude/skills/review-pr/SKILL.md +5 -3
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/target-check/references/domains.md +1 -1
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit-checks.md +3 -29
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +24 -18
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/skills-audit.md +8 -1
- package/docs/target-projects.md +2 -2
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/context.ts +11 -111
- package/src/commands/markdown.ts +63 -2
- package/src/commands/sync.ts +2 -2
- package/src/context/audit.ts +9 -153
- package/src/gate/measures.ts +97 -0
- package/src/gate/stages.ts +21 -2
- package/src/gov/adapter.ts +8 -6
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +7 -1
- package/src/sync/engine.ts +90 -10
- package/src/sync/stamp.ts +44 -0
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/plan.md +24 -1
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- package/src/context/narration.ts +0 -99
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
package/src/tooling/inject.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { copyPreservingMode } from '@/copy'
|
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
|
|
7
7
|
import { resolveSurfacePath } from '@/surface-root'
|
|
8
8
|
import { mergeSections, pruneSections } from '@/tooling/gitignore'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ancestorsFirst,
|
|
11
|
+
configPaths,
|
|
12
|
+
listFiles,
|
|
13
|
+
type Manifest,
|
|
14
|
+
} from '@/tooling/manifest'
|
|
10
15
|
import {
|
|
11
16
|
applyScripts,
|
|
12
17
|
collectDeps,
|
|
@@ -97,9 +102,14 @@ export async function injectSeeds(
|
|
|
97
102
|
target: string,
|
|
98
103
|
): Promise<string[]> {
|
|
99
104
|
const applied: string[] = []
|
|
105
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
106
|
+
const shadowed = configPaths(chain)
|
|
100
107
|
|
|
101
108
|
for (const manifest of ancestorsFirst(chain)) {
|
|
102
|
-
const files = listFiles(manifest.seedsDir)
|
|
109
|
+
const files = listFiles(manifest.seedsDir).filter(
|
|
110
|
+
(rel) =>
|
|
111
|
+
!shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
112
|
+
)
|
|
103
113
|
if (files.length === 0) continue
|
|
104
114
|
|
|
105
115
|
logStep(`Applying ${manifest.name} seeds`)
|
|
@@ -111,9 +121,7 @@ export async function injectSeeds(
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
const spellConfig =
|
|
115
|
-
? subfolderSpellConfig(chain)
|
|
116
|
-
: undefined
|
|
124
|
+
const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
|
|
117
125
|
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
126
|
logStep('Applying subfolder spell config')
|
|
119
127
|
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -44,6 +44,15 @@ export function listFiles(dir: string): string[] {
|
|
|
44
44
|
].sort()
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every path any stack in the chain ships as a config. A seed at one of these
|
|
49
|
+
* paths is shadowed, since the config overwrites it on every sync. `scan` and
|
|
50
|
+
* `injectSeeds` both read this, so the report and the write cannot disagree.
|
|
51
|
+
*/
|
|
52
|
+
export function configPaths(chain: readonly Manifest[]): Set<string> {
|
|
53
|
+
return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Reverses a chain so the furthest ancestor is applied first and nearer
|
|
49
58
|
* stacks overwrite it.
|
package/src/tooling/scan.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { resolveSurfacePath } from '@/surface-root'
|
|
4
4
|
import { mergeSections } from '@/tooling/gitignore'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ancestorsFirst,
|
|
7
|
+
configPaths,
|
|
8
|
+
listFiles,
|
|
9
|
+
type Manifest,
|
|
10
|
+
} from '@/tooling/manifest'
|
|
6
11
|
import {
|
|
7
12
|
collectDeps,
|
|
8
13
|
collectScripts,
|
|
@@ -63,10 +68,13 @@ function isIdentical(a: string, b: string): boolean {
|
|
|
63
68
|
* stack first, while dependencies and gitignore entries resolve from the
|
|
64
69
|
* furthest ancestor inward.
|
|
65
70
|
*
|
|
66
|
-
* A
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
71
|
+
* A seed at a path the chain also ships as a config is left to the config,
|
|
72
|
+
* so the path is reported once.
|
|
73
|
+
*
|
|
74
|
+
* A target below its git toplevel is scanned as a subfolder: a config or seed
|
|
75
|
+
* GitHub reads only at the root moves to `withheld` and counts as no change,
|
|
76
|
+
* and the nested spell config joins the seeds. The scope is read here so every
|
|
77
|
+
* caller of `scan` agrees without passing a flag.
|
|
70
78
|
*/
|
|
71
79
|
export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
72
80
|
const isSubfolder = isSubfolderTarget(target)
|
|
@@ -98,13 +106,22 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
const seeds: SeedState[] = []
|
|
101
|
-
const seenSeeds =
|
|
109
|
+
const seenSeeds = configPaths(chain)
|
|
102
110
|
|
|
103
111
|
for (const manifest of chain) {
|
|
104
112
|
for (const rel of listFiles(manifest.seedsDir)) {
|
|
105
113
|
if (seenSeeds.has(rel)) continue
|
|
106
114
|
seenSeeds.add(rel)
|
|
107
115
|
|
|
116
|
+
if (isSubfolder && isWithheldInSubfolder(rel)) {
|
|
117
|
+
withheld.push({
|
|
118
|
+
rel,
|
|
119
|
+
stack: manifest.name,
|
|
120
|
+
present: existsSync(join(target, rel)),
|
|
121
|
+
})
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
seeds.push({
|
|
109
126
|
rel,
|
|
110
127
|
state: existsSync(resolveSurfacePath(target, rel))
|
package/src/tooling/subfolder.ts
CHANGED
|
@@ -50,9 +50,9 @@ export function subfolderPath(target: string): string | undefined {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* GitHub reads workflows only at the repository root, so a `.github/` copied
|
|
53
|
-
* into a subfolder is a file nothing runs. `scan
|
|
54
|
-
* read this, so the set the diff withholds and the set the
|
|
55
|
-
* disagree.
|
|
53
|
+
* into a subfolder is a file nothing runs. `scan`, `injectConfigs`, and
|
|
54
|
+
* `injectSeeds` all read this, so the set the diff withholds and the set the
|
|
55
|
+
* sync skips cannot disagree, whether the path ships as a config or a seed.
|
|
56
56
|
*/
|
|
57
57
|
export function isWithheldInSubfolder(rel: string): boolean {
|
|
58
58
|
return rel.startsWith(WITHHELD_PREFIX)
|
package/standards/context.md
CHANGED
|
@@ -105,8 +105,8 @@ Only the `development` entry carries this section. It is not a general-purpose h
|
|
|
105
105
|
|
|
106
106
|
## Length
|
|
107
107
|
|
|
108
|
-
- Aim for one entry per domain.
|
|
109
|
-
- Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number. Rendered lines count as `markdown.md` defines them.
|
|
108
|
+
- Aim for one entry per domain. Length is a symptom, not the defect, and the one hard cap is the whole-document ceiling `markdown.md` states.
|
|
109
|
+
- Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number. The 150 is a diagnostic checkpoint and never gates. Rendered lines count as `markdown.md` defines them.
|
|
110
110
|
- Never cut a `## Decisions` or `## Gotchas` entry to shorten a file. Cut a `## Layout` or `## CLI` section instead.
|
|
111
111
|
- Retire a decision or gotcha once its subject is gone, rewriting the bullet to state the current design rather than leaving the narration of what it replaced beside it. A rejected alternative is not a retired one, so what was tried and why it lost stays whatever its age. The rule above protects content whose subject is live, and this one releases content whose subject is not.
|
|
112
112
|
- Rewrite a decision a later one replaced rather than appending the replacement beside it. The subject is still live, so the rule above does not reach it, and two bullets on one subject leave a reader to work out which of them is current. State the design that stands and keep the superseded reasoning only where it is the alternative that lost.
|
package/standards/markdown.md
CHANGED
|
@@ -32,6 +32,8 @@ Does not govern:
|
|
|
32
32
|
- Proper nouns and product names retain their casing in headings
|
|
33
33
|
- Past roughly 40 rendered lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint, not a cap.
|
|
34
34
|
- Count rendered lines rather than source lines, wrapping each source line at 80 columns and summing the heights. Source lines undercount a file authored one line per bullet, where a block of fifteen paragraph-bullets occupies fifteen lines and renders past sixty. A checkpoint another standard states counts the same unit, so a file measured one way never sits beside a run measured another.
|
|
35
|
+
- Keep a whole document at or under 300 rendered lines, counted over the full source with frontmatter and fenced blocks included, since a session pays for every line it loads. This one is a ceiling rather than a checkpoint. `canon markdown audit` lists every document past it without failing the run, and a gate stage reads the same number, so whether a push fails on it is the project's gate to decide. Split the document at a seam rather than compressing it.
|
|
36
|
+
- Exempt a file named `CHANGELOG.md`, which a release tool rewrites, and a document carrying `<!-- canon-length-exempt: <reason> -->` on a line of its own outside a fenced block. A marker naming no reason exempts nothing.
|
|
35
37
|
- Exempt a block whose lines are all list items at one level averaging under roughly 130 characters. A flat list of short peers is already navigable, and a subheading dropped into it splits a set that belongs together. Bullet count says nothing on its own, since a catalog of one-liners and a stack of paragraphs reach the same count and read nothing alike, so weight is what decides.
|
|
36
38
|
- Mixing prose with that list, or nesting levels inside it, ends the exemption at any weight.
|
|
37
39
|
- Exempt a block whose lines are all table rows, at any length. The peer list above is exempt because it is already navigable, and a table because the remedy does not exist: a subheading dropped inside one splits the table rather than the run, so no edit short of rewriting it as a list clears the checkpoint.
|
package/standards/plan.md
CHANGED
|
@@ -31,6 +31,7 @@ A plan works when a session holding none of the conversation that produced it ca
|
|
|
31
31
|
- Which decisions are already settled, and which are still open?
|
|
32
32
|
- For each open decision, what happens when nobody answers it?
|
|
33
33
|
- What is likely to go wrong, and where does the work collide with something already in flight?
|
|
34
|
+
- How is each outcome proven, and which input would break it?
|
|
34
35
|
|
|
35
36
|
A plan failing these is non-conforming even when it satisfies every shape rule below.
|
|
36
37
|
|
|
@@ -53,10 +54,12 @@ The document opens with `# Feature: <short title>` and one paragraph stating wha
|
|
|
53
54
|
| `## Summary` | three to five one-line bullets: the goal, the deliverables, the trade-off | Always |
|
|
54
55
|
| `**Constraints:**` | durable rules the work must respect | When supplied |
|
|
55
56
|
| `**Files to touch:**` | each file with a one-line reason | Always |
|
|
57
|
+
| `**Verification:**` | the test or command that proves each outcome | Always |
|
|
56
58
|
| `**Risks:**` | conflicts, coupling, and the tricky spots | Always |
|
|
59
|
+
| `**Review focus:**` | the inputs or states that would break the change, for the reviewer | Always |
|
|
57
60
|
| `**Questions:**` | numbered open decisions, each with a suggestion and an answer slot | Always |
|
|
58
61
|
|
|
59
|
-
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the
|
|
62
|
+
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the six that follow are labels over lists rather than sections of prose.
|
|
60
63
|
- A section written in the other spelling is still that section. `## Risks` and `**Risks:**` name one thing, and a plan carrying either has stated its risks, so neither reads as an omission. Write the table's form in a new plan and leave an existing plan's spelling alone.
|
|
61
64
|
- Both spellings break a depth run, so `canon markdown audit` reads either as the seam it is and reports a plan for a section that genuinely runs long rather than for its whole body. The markdown standard states the rule.
|
|
62
65
|
- Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
|
|
@@ -87,11 +90,23 @@ A dead constraint fails silently in the expensive direction. A session honoring
|
|
|
87
90
|
|
|
88
91
|
A constraint naming a `.canon/ready/` folder is a third shape beside the two above. It makes that folder's files the verbatim source for the paths this plan's `**Files to touch:**` lists, per `ready.md`, so the executing session copies those paths rather than authoring them.
|
|
89
92
|
|
|
93
|
+
### Verification
|
|
94
|
+
|
|
95
|
+
- Name, for each outcome, the test or the command that proves it, as one bullet leading with the outcome and ending on the command in backticks.
|
|
96
|
+
- Write a command a later session can run as given, from the project root. A description of what would be checked leaves the executing session to invent the check.
|
|
97
|
+
- Name the visual check where no test reaches, and say it is visual, so a reader can tell a proof from a look.
|
|
98
|
+
|
|
90
99
|
### Risks
|
|
91
100
|
|
|
92
101
|
- Name the collision rather than the category. A risk a reader cannot act on is padding.
|
|
93
102
|
- Where the work establishes a resource with more than one consumer, list the consumers and mark each one read or write. A policy stated over that resource has to hold for the writers and not only for the consumer that prompted it.
|
|
94
103
|
|
|
104
|
+
### Review focus
|
|
105
|
+
|
|
106
|
+
- Name each input or state that would break the change if the implementation got it wrong, one bullet each, with what the change must do there.
|
|
107
|
+
- Write it for the reviewer rather than the builder. The session writing the plan understood the risk before any diff existed, and this section is where that understanding reaches the review pass.
|
|
108
|
+
- Keep it apart from `**Risks:**`. A risk is something the work might collide with, and a focus item is something the finished diff must be shown to handle, so a reviewer confirms the second and the executing session plans around the first.
|
|
109
|
+
|
|
95
110
|
## The suggested-and-answer contract
|
|
96
111
|
|
|
97
112
|
Every question carries a `- Suggested:` line and an empty `- Answer:` slot below it.
|
|
@@ -165,10 +180,18 @@ This contract inverts the one an intake folder keeps, where an empty slot means
|
|
|
165
180
|
|
|
166
181
|
- `<path/to/file>`: <reason>
|
|
167
182
|
|
|
183
|
+
**Verification:**
|
|
184
|
+
|
|
185
|
+
- <outcome>: `<test or command that proves it>`
|
|
186
|
+
|
|
168
187
|
**Risks:**
|
|
169
188
|
|
|
170
189
|
- <conflict, coupling, or tricky spot>
|
|
171
190
|
|
|
191
|
+
**Review focus:**
|
|
192
|
+
|
|
193
|
+
- <input or state that would break the change>: <what the change must do there>
|
|
194
|
+
|
|
172
195
|
**Questions:**
|
|
173
196
|
|
|
174
197
|
1. <question>
|
package/standards/skill.md
CHANGED
|
@@ -180,6 +180,7 @@ Without this skill, a session <observed failure>, <observed failure>.
|
|
|
180
180
|
- Contain only behavioral rules (what to do, what not to do) and pointers to reference docs. Narrative descriptions of what files are or how the system works belong in `docs/`, not in the skill body.
|
|
181
181
|
- State rules, not inventories. Reference docs for lists that change, and phrase a rule as a ban on the forbidden shape rather than an enumeration of allowed options, so it stays stable as categories change.
|
|
182
182
|
- Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
|
|
183
|
+
- State the current rule only, in the body and in every file under `references/`. The incident and the date that earned a rule go in `REQUIREMENT.md` under `Gap`, or stay in git. A date inside a fence or a code span is example data and stays. `canon claude skills audit` names every other ISO date.
|
|
183
184
|
- Group bullets under H2 headings by domain concern. Keep dos and don'ts together under the topic they belong to rather than splitting them into flat rules and constraints sections.
|
|
184
185
|
- One actionable constraint per bullet. Prefer the `X over Y` form for preferences.
|
|
185
186
|
- Do not include code examples unless a one to three line inline snippet captures a pattern the model cannot infer.
|
|
@@ -15,8 +15,6 @@ Golden config files live in `tooling/base/configs/` and are copied into the targ
|
|
|
15
15
|
- `.editorconfig`: `root = true`, with an `[*.sh]` block setting `indent_style = space` and `indent_size = 2`.
|
|
16
16
|
- `commitlint.config.js`: ESM default export extending `@commitlint/config-conventional`. Rules are `header-max-length: 72`, `scope-case: lower-case`, `subject-full-stop: never`, and `subject-case` disabled.
|
|
17
17
|
- `.husky/`: `pre-commit`, `commit-msg`, `pre-push`, `post-merge`, `post-rewrite`.
|
|
18
|
-
- `.github/workflows/verify.yml`: runs on pull requests targeting `main` and on `workflow_dispatch`.
|
|
19
|
-
- `.github/pull_request_template.md`: `## Summary`, `## Key Changes`, `## Technical Context`, `## Testing`.
|
|
20
18
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for Prettier, cspell, shfmt, and shellcheck.
|
|
21
19
|
- `scripts/verify.sh`: the maintenance entry point behind `check`.
|
|
22
20
|
|
|
@@ -28,6 +26,8 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
|
|
|
28
26
|
- `.cspell/project-terms.txt` and `.cspell/tech-stack.txt`: one word per line, sorted alphabetically.
|
|
29
27
|
- `.lintstagedrc`: the glob map below.
|
|
30
28
|
- `.prettierignore`: created empty. Projects add their own entries.
|
|
29
|
+
- `.github/workflows/verify.yml`: runs on pull requests targeting `main` and on `workflow_dispatch`. A project with more than one language root adds its own jobs here.
|
|
30
|
+
- `.github/pull_request_template.md`: `## Summary`, `## Key Changes`, `## Technical Context`, `## Testing`. A project adds the checklist lines its own review needs.
|
|
31
31
|
- `canon/context/`: extend the `ci` and `development` entries with project-specific commands, workflows, or deploy steps. Canonical rationale stays in this reference.
|
|
32
32
|
|
|
33
33
|
## Tool pairing
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Page } from '@playwright/test'
|
|
1
|
+
import type { Locator, Page } from '@playwright/test'
|
|
2
2
|
import { chromium } from '@playwright/test'
|
|
3
3
|
import { mkdir } from 'fs/promises'
|
|
4
4
|
import path from 'path'
|
|
@@ -37,6 +37,8 @@ interface CaptureCase {
|
|
|
37
37
|
/** Names of viewports this case skips, for a route that reflows rather than breaks at that width. */
|
|
38
38
|
excludeViewports?: readonly string[]
|
|
39
39
|
evidence?: boolean
|
|
40
|
+
/** Selectors painted over in every frame the case writes, for a value that moves between runs with no code change. */
|
|
41
|
+
mask?: readonly string[]
|
|
40
42
|
sections?: CaptureSection[]
|
|
41
43
|
setup?: (page: Page) => Promise<void>
|
|
42
44
|
}
|
|
@@ -72,12 +74,14 @@ const CASES: CaptureCase[] = [
|
|
|
72
74
|
theme: 'default',
|
|
73
75
|
route: '/',
|
|
74
76
|
evidence: true,
|
|
77
|
+
mask: ['.version'],
|
|
75
78
|
sections: HOME_SECTIONS,
|
|
76
79
|
},
|
|
77
80
|
{
|
|
78
81
|
section: 'home',
|
|
79
82
|
theme: 'dark',
|
|
80
83
|
route: '/',
|
|
84
|
+
mask: ['.version'],
|
|
81
85
|
sections: HOME_SECTIONS,
|
|
82
86
|
setup: (page) => page.emulateMedia({ colorScheme: 'dark' }),
|
|
83
87
|
},
|
|
@@ -234,16 +238,47 @@ async function markFloatingChrome(page: Page) {
|
|
|
234
238
|
}, FLOATING_ATTRIBUTE)
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Turns a case's mask selectors into the locators every frame paints over. A
|
|
243
|
+
* selector matching nothing is reported and kept out rather than raised, for
|
|
244
|
+
* the same template reason `settle` gives for a missing section.
|
|
245
|
+
*/
|
|
246
|
+
async function resolveMask(
|
|
247
|
+
page: Page,
|
|
248
|
+
label: string,
|
|
249
|
+
selectors: readonly string[],
|
|
250
|
+
): Promise<Locator[]> {
|
|
251
|
+
const mask: Locator[] = []
|
|
252
|
+
for (const selector of selectors) {
|
|
253
|
+
const element = page.locator(selector)
|
|
254
|
+
if ((await element.count()) === 0) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`${label}: no element matches ${selector}, named as a mask. Skipped.`,
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
mask.push(element)
|
|
261
|
+
}
|
|
262
|
+
return mask
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function shoot(
|
|
266
|
+
page: Page,
|
|
267
|
+
dir: string,
|
|
268
|
+
file: string,
|
|
269
|
+
mask: Locator[],
|
|
270
|
+
selector?: string,
|
|
271
|
+
) {
|
|
238
272
|
await mkdir(dir, { recursive: true })
|
|
239
273
|
const target = path.join(dir, file)
|
|
240
274
|
if (selector === undefined) {
|
|
241
|
-
await page.screenshot({ path: target, fullPage: true })
|
|
275
|
+
await page.screenshot({ path: target, fullPage: true, mask })
|
|
242
276
|
} else {
|
|
243
277
|
// Floating chrome keeps the space it already occupies, so dropping it out
|
|
244
278
|
// of its own layer moves nothing and leaves every box the size it was.
|
|
245
279
|
await page.locator(selector).screenshot({
|
|
246
280
|
path: target,
|
|
281
|
+
mask,
|
|
247
282
|
style: `[${FLOATING_ATTRIBUTE}] { position: static !important; }`,
|
|
248
283
|
})
|
|
249
284
|
}
|
|
@@ -281,21 +316,22 @@ for (const { captureCase, viewport, label } of RUNS) {
|
|
|
281
316
|
await page.waitForLoadState('networkidle')
|
|
282
317
|
|
|
283
318
|
const sections = await settle(page, label, captureCase.sections ?? [])
|
|
319
|
+
const mask = await resolveMask(page, label, captureCase.mask ?? [])
|
|
284
320
|
|
|
285
321
|
const sweepDir = path.join(OUT_DIR, captureCase.section)
|
|
286
322
|
const evidenceDir = path.join('evidence', captureCase.section)
|
|
287
323
|
const file = `${viewport.name}--${captureCase.theme}.png`
|
|
288
324
|
const writesEvidence = captureCase.evidence && viewport.evidence
|
|
289
325
|
|
|
290
|
-
await shoot(page, sweepDir, file)
|
|
291
|
-
if (writesEvidence) await shoot(page, evidenceDir, file)
|
|
326
|
+
await shoot(page, sweepDir, file, mask)
|
|
327
|
+
if (writesEvidence) await shoot(page, evidenceDir, file, mask)
|
|
292
328
|
|
|
293
329
|
if (sections.length > 0) await markFloatingChrome(page)
|
|
294
330
|
|
|
295
331
|
for (const { name, selector } of sections) {
|
|
296
|
-
await shoot(page, path.join(sweepDir, name), file, selector)
|
|
332
|
+
await shoot(page, path.join(sweepDir, name), file, mask, selector)
|
|
297
333
|
if (writesEvidence) {
|
|
298
|
-
await shoot(page, path.join(evidenceDir, name), file, selector)
|
|
334
|
+
await shoot(page, path.join(evidenceDir, name), file, mask, selector)
|
|
299
335
|
}
|
|
300
336
|
}
|
|
301
337
|
|
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, and a route, and the loop below crosses every case with the `VIEWPORTS` array and writes `screenshots/<hostname>/<section>/<width>--<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. `VIEWPORTS` holds four breakpoint buckets named by width, 320, 768, 1280 and 1536, with 320 as the base a page has to hold without wrapping or clipping. A case flagged `evidence: true` additionally writes `evidence/<section>/<width>--<theme>.png`, with no hostname segment, at the viewports flagged `evidence` alone, which are 320 and 1280. Committing all four multiplies the tracked frames by the width count, so the flag is what keeps the tracked set at twice the sections rather than four times. A case may name `excludeViewports` to skip a width, for a route whose long-form prose reflows at 320 instead of breaking. `SCREENSHOT_FILTER` narrows a run to the cases whose label, `<section>/<width>--<theme>`, contains any comma-separated term, exits 1 when nothing matches and lists the labels that exist, and leaves the rest of the sweep alone. `--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 widths and themes sit together under its section folder, so the filename carries both. 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>/<width>--<theme>.png` beside the whole-page one, plus `evidence/<section>/<name>/<width>--<theme>.png` where the case is flagged and the width carries `evidence`. 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`.
|
|
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, and a route, and the loop below crosses every case with the `VIEWPORTS` array and writes `screenshots/<hostname>/<section>/<width>--<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. `VIEWPORTS` holds four breakpoint buckets named by width, 320, 768, 1280 and 1536, with 320 as the base a page has to hold without wrapping or clipping. A case flagged `evidence: true` additionally writes `evidence/<section>/<width>--<theme>.png`, with no hostname segment, at the viewports flagged `evidence` alone, which are 320 and 1280. Committing all four multiplies the tracked frames by the width count, so the flag is what keeps the tracked set at twice the sections rather than four times. A case may name `excludeViewports` to skip a width, for a route whose long-form prose reflows at 320 instead of breaking. `SCREENSHOT_FILTER` narrows a run to the cases whose label, `<section>/<width>--<theme>`, contains any comma-separated term, exits 1 when nothing matches and lists the labels that exist, and leaves the rest of the sweep alone. `--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 widths and themes sit together under its section folder, so the filename carries both. 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>/<width>--<theme>.png` beside the whole-page one, plus `evidence/<section>/<name>/<width>--<theme>.png` where the case is flagged and the width carries `evidence`. 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. A case may also name `mask`, a list of selectors whose boxes are painted over in every frame the case writes, whole-page and section alike, for a value that moves between runs with no code change, so a double capture still compares everything else. A mask selector matching nothing is reported and skipped the same way. 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
|
- `scripts/screenshot.sh`: sources `scripts/lib/preview-server.sh`, then runs the capture against the served preview.
|
|
17
17
|
- `scripts/lib/preview-server.sh`: sourced, never executed. Builds, serves on `PREVIEW_PORT`, blocks until the port answers, and traps the whole process group on exit, including the detached grandchild `lsof` finds. Both capture scripts source it so the startup lives once.
|
|
18
18
|
- `scripts/readme-screenshot.sh`: captures one case under `SCREENSHOT_FILTER` and copies its light and dark frames to `assets/evidence/readme/`. `README_FILTER` ships empty with `header/1280` as its example, and the script exits 1 until a project fills it, since a shipped default would capture the wrong part of the page in every project but one. Its copy reads from `screenshots/<hostname>/`, which is where the harness writes.
|
package/src/context/narration.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The headings a rule carries to publish the two sets.
|
|
7
|
-
*
|
|
8
|
-
* Discovery is anchored on these rather than on a filename for the reason
|
|
9
|
-
* `src/comments/vocabulary.ts` anchors its own: governance rules are numbered,
|
|
10
|
-
* and a renumber would empty the sets while the check kept reporting clean.
|
|
11
|
-
* Both open with `Narration` so neither loader can read the other's list.
|
|
12
|
-
*/
|
|
13
|
-
export const PRONOUN_HEADING = '## Narration pronouns'
|
|
14
|
-
export const VERB_HEADING = '## Narration verbs'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Roots searched in order. The installed copy wins over the toolkit source, so
|
|
18
|
-
* a target project measures against the rule it actually has rather than one
|
|
19
|
-
* only the toolkit carries.
|
|
20
|
-
*/
|
|
21
|
-
const RULE_ROOTS = ['.claude/rules', 'governance/rules']
|
|
22
|
-
|
|
23
|
-
export interface NarrationTerms {
|
|
24
|
-
/** Back-reference openings, matched at the start of a bullet and cased. */
|
|
25
|
-
readonly pronouns: readonly string[]
|
|
26
|
-
/** Past-tense verbs, matched anywhere in the bullet and uncased. */
|
|
27
|
-
readonly verbs: readonly string[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Absent is a distinct state from empty, the split the comment vocabulary
|
|
32
|
-
* draws for the same reason: a scan with no terms finds nothing, and reporting
|
|
33
|
-
* that as zero findings claims the corpus is clean when nothing was looked for.
|
|
34
|
-
*/
|
|
35
|
-
export type Narration =
|
|
36
|
-
| ({ readonly kind: 'loaded'; readonly source: string } & NarrationTerms)
|
|
37
|
-
| { readonly kind: 'absent' }
|
|
38
|
-
|
|
39
|
-
/** Pulls the backticked terms out of the bullets under one heading. */
|
|
40
|
-
export function parseTerms(
|
|
41
|
-
markdown: string,
|
|
42
|
-
heading: string,
|
|
43
|
-
): string[] | undefined {
|
|
44
|
-
const lines = markdown.split('\n')
|
|
45
|
-
const start = lines.findIndex((line) => line.trim() === heading)
|
|
46
|
-
if (start === -1) return undefined
|
|
47
|
-
|
|
48
|
-
const terms: string[] = []
|
|
49
|
-
|
|
50
|
-
for (const line of lines.slice(start + 1)) {
|
|
51
|
-
if (line.startsWith('## ')) break
|
|
52
|
-
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
53
|
-
const term = match[1].trim()
|
|
54
|
-
if (term && !terms.includes(term)) terms.push(term)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return terms
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Finds the rule publishing both sets under `root`.
|
|
63
|
-
*
|
|
64
|
-
* Both come from one file rather than from whichever rule carries each. A
|
|
65
|
-
* pronoun set and a verb set are halves of one signal, and pairing them across
|
|
66
|
-
* files would let a renumber recombine them into a signal nobody wrote.
|
|
67
|
-
*/
|
|
68
|
-
export async function loadNarration(root: string): Promise<Narration> {
|
|
69
|
-
for (const ruleRoot of RULE_ROOTS) {
|
|
70
|
-
const dir = resolve(root, ruleRoot)
|
|
71
|
-
if (!existsSync(dir)) continue
|
|
72
|
-
|
|
73
|
-
const paths: string[] = []
|
|
74
|
-
for await (const rel of new Bun.Glob('**/*.md').scan({
|
|
75
|
-
cwd: dir,
|
|
76
|
-
onlyFiles: true,
|
|
77
|
-
})) {
|
|
78
|
-
paths.push(rel)
|
|
79
|
-
}
|
|
80
|
-
paths.sort()
|
|
81
|
-
|
|
82
|
-
for (const rel of paths) {
|
|
83
|
-
const source = await readFile(resolve(dir, rel), 'utf8')
|
|
84
|
-
const pronouns = parseTerms(source, PRONOUN_HEADING)
|
|
85
|
-
const verbs = parseTerms(source, VERB_HEADING)
|
|
86
|
-
|
|
87
|
-
if (pronouns && verbs && pronouns.length > 0 && verbs.length > 0) {
|
|
88
|
-
return {
|
|
89
|
-
kind: 'loaded',
|
|
90
|
-
source: `${ruleRoot}/${rel}`,
|
|
91
|
-
pronouns,
|
|
92
|
-
verbs,
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { kind: 'absent' }
|
|
99
|
-
}
|
|
File without changes
|
|
File without changes
|